diff --git a/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/BookRepository.kt b/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/BookRepository.kt index c280f932..951b536f 100644 --- a/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/BookRepository.kt +++ b/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/BookRepository.kt @@ -7,14 +7,15 @@ import kotlinx.coroutines.flow.Flow import com.ninecraft.booket.core.model.LibraryModel interface BookRepository { - val recentSearches: Flow> + val bookRecentSearches: Flow> + val libraryRecentSearches: Flow> suspend fun searchBook( query: String, start: Int, ): Result - suspend fun removeRecentSearch(query: String) + suspend fun removeBookRecentSearch(query: String) suspend fun getBookDetail(itemId: String): Result @@ -23,9 +24,17 @@ interface BookRepository { bookStatus: String, ): Result - suspend fun getLibrary( + suspend fun filterLibraryBooks( status: String?, page: Int, size: Int, ): Result + + suspend fun searchLibraryBooks( + title: String, + page: Int, + size: Int, + ): Result + + suspend fun removeLibraryRecentSearch(query: String) } diff --git a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/mapper/ResponseToModel.kt b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/mapper/ResponseToModel.kt index c4c5d19d..59061732 100644 --- a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/mapper/ResponseToModel.kt +++ b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/mapper/ResponseToModel.kt @@ -54,7 +54,7 @@ internal fun BookSearchResponse.toModel(): BookSearchModel { internal fun BookSummary.toModel(): BookSummaryModel { return BookSummaryModel( - isbn = isbn, + isbn13 = isbn13, title = title.decodeHtmlEntities(), author = author, publisher = publisher, diff --git a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultBookRepository.kt b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultBookRepository.kt index 9e8454b2..c5780bc9 100644 --- a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultBookRepository.kt +++ b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultBookRepository.kt @@ -3,16 +3,19 @@ package com.ninecraft.booket.core.data.impl.repository import com.ninecraft.booket.core.common.utils.runSuspendCatching import com.ninecraft.booket.core.data.api.repository.BookRepository import com.ninecraft.booket.core.data.impl.mapper.toModel -import com.ninecraft.booket.core.datastore.api.datasource.RecentSearchDataSource +import com.ninecraft.booket.core.datastore.api.datasource.BookRecentSearchDataSource +import com.ninecraft.booket.core.datastore.api.datasource.LibraryRecentSearchDataSource import com.ninecraft.booket.core.network.request.BookUpsertRequest import com.ninecraft.booket.core.network.service.ReedService import javax.inject.Inject internal class DefaultBookRepository @Inject constructor( private val service: ReedService, - private val dataSource: RecentSearchDataSource, + private val bookRecentSearchDataSource: BookRecentSearchDataSource, + private val libraryRecentSearchDataSource: LibraryRecentSearchDataSource, ) : BookRepository { - override val recentSearches = dataSource.recentSearches + override val bookRecentSearches = bookRecentSearchDataSource.recentSearches + override val libraryRecentSearches = libraryRecentSearchDataSource.recentSearches override suspend fun searchBook( query: String, @@ -23,12 +26,12 @@ internal class DefaultBookRepository @Inject constructor( start = start, ).toModel() - dataSource.addRecentSearch(query) + bookRecentSearchDataSource.addRecentSearch(query) result } - override suspend fun removeRecentSearch(query: String) { - dataSource.removeRecentSearch(query) + override suspend fun removeBookRecentSearch(query: String) { + bookRecentSearchDataSource.removeRecentSearch(query) } override suspend fun getBookDetail(itemId: String) = runSuspendCatching { @@ -39,7 +42,18 @@ internal class DefaultBookRepository @Inject constructor( service.upsertBook(BookUpsertRequest(bookIsbn, bookStatus)).toModel() } - override suspend fun getLibrary(status: String?, page: Int, size: Int) = runSuspendCatching { - service.getLibrary(status, page, size).toModel() + override suspend fun filterLibraryBooks(status: String?, page: Int, size: Int) = runSuspendCatching { + service.getLibraryBooks(status, null, page, size).toModel() + } + + override suspend fun searchLibraryBooks(title: String, page: Int, size: Int) = runSuspendCatching { + val result = service.getLibraryBooks(null, title, page, size).toModel() + + libraryRecentSearchDataSource.addRecentSearch(title) + result + } + + override suspend fun removeLibraryRecentSearch(query: String) { + libraryRecentSearchDataSource.removeRecentSearch(query) } } diff --git a/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/BookRecentSearchDataSource.kt b/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/BookRecentSearchDataSource.kt new file mode 100644 index 00000000..cf81501f --- /dev/null +++ b/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/BookRecentSearchDataSource.kt @@ -0,0 +1,3 @@ +package com.ninecraft.booket.core.datastore.api.datasource + +interface BookRecentSearchDataSource : RecentSearchDataSource diff --git a/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/LibraryRecentSearchDataSource.kt b/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/LibraryRecentSearchDataSource.kt new file mode 100644 index 00000000..ecc9273c --- /dev/null +++ b/core/datastore/api/src/main/kotlin/com/ninecraft/booket/core/datastore/api/datasource/LibraryRecentSearchDataSource.kt @@ -0,0 +1,3 @@ +package com.ninecraft.booket.core.datastore.api.datasource + +interface LibraryRecentSearchDataSource : RecentSearchDataSource diff --git a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultRecentSearchDataSource.kt b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultBookRecentSearchDataSource.kt similarity index 79% rename from core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultRecentSearchDataSource.kt rename to core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultBookRecentSearchDataSource.kt index 974fa5cc..9dab0359 100644 --- a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultRecentSearchDataSource.kt +++ b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultBookRecentSearchDataSource.kt @@ -4,8 +4,8 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import com.ninecraft.booket.core.datastore.api.datasource.RecentSearchDataSource -import com.ninecraft.booket.core.datastore.impl.di.RecentSearchDataStore +import com.ninecraft.booket.core.datastore.api.datasource.BookRecentSearchDataSource +import com.ninecraft.booket.core.datastore.impl.di.BookRecentSearchDataStore import com.ninecraft.booket.core.datastore.impl.util.handleIOException import com.orhanobut.logger.Logger import kotlinx.coroutines.flow.Flow @@ -14,14 +14,14 @@ import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import javax.inject.Inject -class DefaultRecentSearchDataSource @Inject constructor( - @RecentSearchDataStore private val dataStore: DataStore, -) : RecentSearchDataSource { +class DefaultBookRecentSearchDataSource @Inject constructor( + @BookRecentSearchDataStore private val dataStore: DataStore, +) : BookRecentSearchDataSource { @Suppress("TooGenericExceptionCaught") override val recentSearches: Flow> = dataStore.data .handleIOException() .map { prefs -> - prefs[RECENT_SEARCHES]?.let { jsonString -> + prefs[BOOK_RECENT_SEARCHES]?.let { jsonString -> try { Json.decodeFromString>(jsonString) } catch (e: SerializationException) { @@ -39,7 +39,7 @@ class DefaultRecentSearchDataSource @Inject constructor( if (query.isBlank()) return dataStore.edit { prefs -> - val currentSearches = prefs[RECENT_SEARCHES]?.let { jsonString -> + val currentSearches = prefs[BOOK_RECENT_SEARCHES]?.let { jsonString -> try { Json.decodeFromString>(jsonString).toMutableList() } catch (e: SerializationException) { @@ -59,7 +59,7 @@ class DefaultRecentSearchDataSource @Inject constructor( // 최근 10개만 유지 val limitedSearches = currentSearches.take(MAX_SEARCH_COUNT) try { - prefs[RECENT_SEARCHES] = Json.encodeToString(limitedSearches) + prefs[BOOK_RECENT_SEARCHES] = Json.encodeToString(limitedSearches) } catch (e: SerializationException) { Logger.e(e, "Failed to serialize recent searches") } @@ -69,7 +69,7 @@ class DefaultRecentSearchDataSource @Inject constructor( @Suppress("TooGenericExceptionCaught") override suspend fun removeRecentSearch(query: String) { dataStore.edit { prefs -> - val currentSearches = prefs[RECENT_SEARCHES]?.let { jsonString -> + val currentSearches = prefs[BOOK_RECENT_SEARCHES]?.let { jsonString -> try { Json.decodeFromString>(jsonString).toMutableList() } catch (e: SerializationException) { @@ -83,7 +83,7 @@ class DefaultRecentSearchDataSource @Inject constructor( currentSearches.remove(query) try { - prefs[RECENT_SEARCHES] = Json.encodeToString(currentSearches) + prefs[BOOK_RECENT_SEARCHES] = Json.encodeToString(currentSearches) } catch (e: SerializationException) { Logger.e(e, "Failed to serialize recent searches after removal") } @@ -92,12 +92,12 @@ class DefaultRecentSearchDataSource @Inject constructor( override suspend fun clearRecentSearches() { dataStore.edit { prefs -> - prefs.remove(RECENT_SEARCHES) + prefs.remove(BOOK_RECENT_SEARCHES) } } companion object { - private val RECENT_SEARCHES = stringPreferencesKey("RECENT_SEARCHES") + private val BOOK_RECENT_SEARCHES = stringPreferencesKey("BOOK_RECENT_SEARCHES") private const val MAX_SEARCH_COUNT = 10 } } diff --git a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultLibraryRecentSearchDataSource.kt b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultLibraryRecentSearchDataSource.kt new file mode 100644 index 00000000..e90c2e70 --- /dev/null +++ b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/datasource/DefaultLibraryRecentSearchDataSource.kt @@ -0,0 +1,103 @@ +package com.ninecraft.booket.core.datastore.impl.datasource + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.ninecraft.booket.core.datastore.api.datasource.LibraryRecentSearchDataSource +import com.ninecraft.booket.core.datastore.impl.di.LibraryRecentSearchDataStore +import com.ninecraft.booket.core.datastore.impl.util.handleIOException +import com.orhanobut.logger.Logger +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import javax.inject.Inject + +class DefaultLibraryRecentSearchDataSource @Inject constructor( + @LibraryRecentSearchDataStore private val dataStore: DataStore, +) : LibraryRecentSearchDataSource { + @Suppress("TooGenericExceptionCaught") + override val recentSearches: Flow> = dataStore.data + .handleIOException() + .map { prefs -> + prefs[LIBRARY_RECENT_SEARCHES]?.let { jsonString -> + try { + Json.decodeFromString>(jsonString) + } catch (e: SerializationException) { + Logger.e(e, "Failed to deserialize recent searches") + emptyList() + } catch (e: Exception) { + Logger.e(e, "Unexpected error while reading recent searches") + emptyList() + } + } ?: emptyList() + } + + @Suppress("TooGenericExceptionCaught") + override suspend fun addRecentSearch(query: String) { + if (query.isBlank()) return + + dataStore.edit { prefs -> + val currentSearches = prefs[LIBRARY_RECENT_SEARCHES]?.let { jsonString -> + try { + Json.decodeFromString>(jsonString).toMutableList() + } catch (e: SerializationException) { + Logger.e(e, "Failed to deserialize recent searches for adding") + mutableListOf() + } catch (e: Exception) { + Logger.e(e, "Unexpected error while adding recent search") + mutableListOf() + } + } ?: mutableListOf() + + // 기존에 있으면 제거 (upsert 로직) + currentSearches.remove(query) + // 맨 앞에 추가 (가장 최근 검색어) + currentSearches.add(0, query) + + // 최근 10개만 유지 + val limitedSearches = currentSearches.take(MAX_SEARCH_COUNT) + try { + prefs[LIBRARY_RECENT_SEARCHES] = Json.encodeToString(limitedSearches) + } catch (e: SerializationException) { + Logger.e(e, "Failed to serialize recent searches") + } + } + } + + @Suppress("TooGenericExceptionCaught") + override suspend fun removeRecentSearch(query: String) { + dataStore.edit { prefs -> + val currentSearches = prefs[LIBRARY_RECENT_SEARCHES]?.let { jsonString -> + try { + Json.decodeFromString>(jsonString).toMutableList() + } catch (e: SerializationException) { + Logger.e(e, "Failed to deserialize recent searches for removal") + mutableListOf() + } catch (e: Exception) { + Logger.e(e, "Unexpected error while removing recent search") + mutableListOf() + } + } ?: mutableListOf() + + currentSearches.remove(query) + try { + prefs[LIBRARY_RECENT_SEARCHES] = Json.encodeToString(currentSearches) + } catch (e: SerializationException) { + Logger.e(e, "Failed to serialize recent searches after removal") + } + } + } + + override suspend fun clearRecentSearches() { + dataStore.edit { prefs -> + prefs.remove(LIBRARY_RECENT_SEARCHES) + } + } + + companion object { + private val LIBRARY_RECENT_SEARCHES = stringPreferencesKey("LIBRARY_RECENT_SEARCHES") + private const val MAX_SEARCH_COUNT = 10 + } +} diff --git a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreModule.kt b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreModule.kt index 3d966396..08e11837 100644 --- a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreModule.kt +++ b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreModule.kt @@ -4,11 +4,13 @@ import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore +import com.ninecraft.booket.core.datastore.api.datasource.BookRecentSearchDataSource +import com.ninecraft.booket.core.datastore.api.datasource.LibraryRecentSearchDataSource import com.ninecraft.booket.core.datastore.api.datasource.OnboardingDataSource -import com.ninecraft.booket.core.datastore.api.datasource.RecentSearchDataSource import com.ninecraft.booket.core.datastore.api.datasource.TokenDataSource +import com.ninecraft.booket.core.datastore.impl.datasource.DefaultLibraryRecentSearchDataSource import com.ninecraft.booket.core.datastore.impl.datasource.DefaultOnboardingDataSource -import com.ninecraft.booket.core.datastore.impl.datasource.DefaultRecentSearchDataSource +import com.ninecraft.booket.core.datastore.impl.datasource.DefaultBookRecentSearchDataSource import com.ninecraft.booket.core.datastore.impl.datasource.DefaultTokenDataSource import dagger.Binds import dagger.Module @@ -22,11 +24,13 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object DataStoreModule { private const val TOKEN_DATASTORE_NAME = "TOKENS_DATASTORE" - private const val RECENT_SEARCH_DATASTORE_NAME = "RECENT_SEARCH_DATASTORE" + private const val BOOK_RECENT_SEARCH_DATASTORE_NAME = "BOOK_RECENT_SEARCH_DATASTORE" + private const val LIBRARY_RECENT_SEARCH_DATASTORE_NAME = "LIBRARY_RECENT_SEARCH_DATASTORE" private const val ONBOARDING_DATASTORE_NAME = "ONBOARDING_DATASTORE" private val Context.tokenDataStore by preferencesDataStore(name = TOKEN_DATASTORE_NAME) - private val Context.recentSearchDataStore by preferencesDataStore(name = RECENT_SEARCH_DATASTORE_NAME) + private val Context.bookRecentSearchDataStore by preferencesDataStore(name = BOOK_RECENT_SEARCH_DATASTORE_NAME) + private val Context.libraryRecentSearchDataStore by preferencesDataStore(name = LIBRARY_RECENT_SEARCH_DATASTORE_NAME) private val Context.onboardingDataStore by preferencesDataStore(name = ONBOARDING_DATASTORE_NAME) @TokenDataStore @@ -36,12 +40,19 @@ object DataStoreModule { @ApplicationContext context: Context, ): DataStore = context.tokenDataStore - @RecentSearchDataStore + @BookRecentSearchDataStore @Provides @Singleton - fun provideRecentSearchDataStore( + fun provideBookRecentSearchDataStore( @ApplicationContext context: Context, - ): DataStore = context.recentSearchDataStore + ): DataStore = context.bookRecentSearchDataStore + + @LibraryRecentSearchDataStore + @Provides + @Singleton + fun provideLibraryRecentSearchDataStore( + @ApplicationContext context: Context, + ): DataStore = context.libraryRecentSearchDataStore @OnboardingDataStore @Provides @@ -63,9 +74,15 @@ abstract class DataStoreBindModule { @Binds @Singleton - abstract fun bindRecentSearchDataSource( - defaultRecentSearchDataSource: DefaultRecentSearchDataSource, - ): RecentSearchDataSource + abstract fun bindBookRecentSearchDataSource( + defaultBookRecentSearchDataSource: DefaultBookRecentSearchDataSource, + ): BookRecentSearchDataSource + + @Binds + @Singleton + abstract fun bindLibraryRecentSearchDataSource( + defaultLibraryRecentSearchDataSource: DefaultLibraryRecentSearchDataSource, + ): LibraryRecentSearchDataSource @Binds @Singleton diff --git a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreQualifier.kt b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreQualifier.kt index 6fa4ee38..49262242 100644 --- a/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreQualifier.kt +++ b/core/datastore/impl/src/main/kotlin/com/ninecraft/booket/core/datastore/impl/di/DataStoreQualifier.kt @@ -8,7 +8,11 @@ annotation class TokenDataStore @Qualifier @Retention(AnnotationRetention.BINARY) -annotation class RecentSearchDataStore +annotation class BookRecentSearchDataStore + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class LibraryRecentSearchDataStore @Qualifier @Retention(AnnotationRetention.BINARY) diff --git a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/textfield/ReedSearchTextField.kt b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/textfield/ReedSearchTextField.kt index ef29da22..880c3748 100644 --- a/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/textfield/ReedSearchTextField.kt +++ b/core/designsystem/src/main/kotlin/com/ninecraft/booket/core/designsystem/component/textfield/ReedSearchTextField.kt @@ -53,7 +53,8 @@ fun ReedTextField( backgroundColor: Color = ReedTheme.colors.baseSecondary, textColor: Color = ReedTheme.colors.contentPrimary, cornerShape: RoundedCornerShape = RoundedCornerShape(ReedTheme.radius.sm), - borderStroke: BorderStroke = BorderStroke(width = 1.dp, color = ReedTheme.colors.borderBrand), + borderStroke: BorderStroke? = null, + searchIconTint: Color = ReedTheme.colors.contentPrimary, ) { val keyboardController = LocalSoftwareKeyboardController.current @@ -77,9 +78,12 @@ fun ReedTextField( Row( modifier = modifier .background(color = backgroundColor, shape = cornerShape) - .border( - border = borderStroke, - shape = cornerShape, + .then( + if (borderStroke != null) { + Modifier.border(borderStroke, shape = cornerShape) + } else { + Modifier + }, ) .padding(vertical = ReedTheme.spacing.spacing3), verticalAlignment = Alignment.CenterVertically, @@ -114,7 +118,7 @@ fun ReedTextField( onSearch(queryState.text.toString()) keyboardController?.hide() }, - tint = ReedTheme.colors.contentBrand, + tint = searchIconTint, ) Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing4)) } diff --git a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/BookSearchModel.kt b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/BookSearchModel.kt index 6dcc8f82..ec3761fa 100644 --- a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/BookSearchModel.kt +++ b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/BookSearchModel.kt @@ -19,7 +19,7 @@ data class BookSearchModel( @Stable data class BookSummaryModel( - val isbn: String = "", + val isbn13: String = "", val title: String = "", val author: String = "", val publisher: String = "", diff --git a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/BookSearchResponse.kt b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/BookSearchResponse.kt index 16513370..be1cb8bf 100644 --- a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/BookSearchResponse.kt +++ b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/response/BookSearchResponse.kt @@ -31,8 +31,8 @@ data class BookSearchResponse( @Serializable data class BookSummary( - @SerialName("isbn") - val isbn: String, + @SerialName("isbn13") + val isbn13: String, @SerialName("title") val title: String, @SerialName("author") diff --git a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/service/ReedService.kt b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/service/ReedService.kt index 80294305..750bc253 100644 --- a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/service/ReedService.kt +++ b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/service/ReedService.kt @@ -64,8 +64,9 @@ interface ReedService { suspend fun upsertBook(@Body bookUpsertRequest: BookUpsertRequest): BookUpsertResponse @GET("api/v1/books/my-library") - suspend fun getLibrary( + suspend fun getLibraryBooks( @Query("status") status: String? = null, + @Query("title") title: String? = null, @Query("page") page: Int, @Query("size") size: Int, @Query("sort") sort: String = "CREATED_DATE_DESC", diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailPresenter.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailPresenter.kt index 5d4c45d2..37283c9b 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailPresenter.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailPresenter.kt @@ -93,7 +93,7 @@ class BookDetailPresenter @AssistedInject constructor( } is BookDetailUiEvent.OnBookStatusUpdateButtonClick -> { - upsertBook(screen.isbn, currentBookStatus.value) + upsertBook(screen.isbn13, currentBookStatus.value) } is BookDetailUiEvent.OnRecordSortBottomSheetDismiss -> { diff --git a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryPresenter.kt b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryPresenter.kt index 48d1890c..d13bf479 100644 --- a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryPresenter.kt +++ b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryPresenter.kt @@ -12,6 +12,7 @@ import com.ninecraft.booket.core.model.LibraryBookSummaryModel import com.ninecraft.booket.core.ui.component.FooterState import com.ninecraft.booket.feature.screens.BookDetailScreen import com.ninecraft.booket.feature.screens.LibraryScreen +import com.ninecraft.booket.feature.screens.LibrarySearchScreen import com.ninecraft.booket.feature.screens.SettingsScreen import com.orhanobut.logger.Logger import com.slack.circuit.codegen.annotations.CircuitInject @@ -31,7 +32,7 @@ class LibraryPresenter @AssistedInject constructor( private val repository: BookRepository, ) : Presenter { companion object { - private const val PAGE_SIZE = 10 + private const val PAGE_SIZE = 20 private const val START_INDEX = 0 } @@ -51,7 +52,7 @@ class LibraryPresenter @AssistedInject constructor( var currentPage by rememberRetained { mutableIntStateOf(START_INDEX) } var isLastPage by rememberRetained { mutableStateOf(false) } - fun getLibraryBooks(status: String?, page: Int, size: Int) { + fun filterLibraryBooks(status: String?, page: Int, size: Int) { scope.launch { if (page == START_INDEX) { uiState = UiState.Loading @@ -59,7 +60,7 @@ class LibraryPresenter @AssistedInject constructor( footerState = FooterState.Loading } - repository.getLibrary(status = status, page = page, size = size) + repository.filterLibraryBooks(status = status, page = page, size = size) .onSuccess { result -> filterChips = filterChips.map { chip -> when (chip.option) { @@ -104,34 +105,38 @@ class LibraryPresenter @AssistedInject constructor( sideEffect = null } + is LibraryUiEvent.OnLibrarySearchClick -> { + navigator.goTo(LibrarySearchScreen) + } + is LibraryUiEvent.OnSettingsClick -> { navigator.goTo(SettingsScreen) } is LibraryUiEvent.OnFilterClick -> { currentFilter = event.filterOption - getLibraryBooks(status = currentFilter.getApiValue(), page = START_INDEX, size = PAGE_SIZE) + filterLibraryBooks(status = currentFilter.getApiValue(), page = START_INDEX, size = PAGE_SIZE) } is LibraryUiEvent.OnBookClick -> { - navigator.goTo(BookDetailScreen(isbn = event.isbn)) + navigator.goTo(BookDetailScreen(isbn13 = event.isbn13)) } is LibraryUiEvent.OnLoadMore -> { if (footerState !is FooterState.Loading && !isLastPage) { - getLibraryBooks(status = currentFilter.getApiValue(), page = currentPage + 1, size = PAGE_SIZE) + filterLibraryBooks(status = currentFilter.getApiValue(), page = currentPage + 1, size = PAGE_SIZE) } } is LibraryUiEvent.OnRetryClick -> { - getLibraryBooks(status = currentFilter.getApiValue(), page = currentPage, size = PAGE_SIZE) + filterLibraryBooks(status = currentFilter.getApiValue(), page = currentPage, size = PAGE_SIZE) } } } LaunchedEffect(Unit) { if (uiState == UiState.Idle || uiState is UiState.Error) { - getLibraryBooks( + filterLibraryBooks( status = currentFilter.getApiValue(), page = START_INDEX, size = PAGE_SIZE, diff --git a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUi.kt b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUi.kt index 71b1ae4b..db489218 100644 --- a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUi.kt +++ b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUi.kt @@ -65,7 +65,7 @@ internal fun LibraryContent( ) { LibraryHeader( onSearchClick = { - // TODO: 내서재 검색 화면으로 이동 + state.eventSink(LibraryUiEvent.OnLibrarySearchClick) }, onSettingClick = { state.eventSink(LibraryUiEvent.OnSettingsClick) diff --git a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUiState.kt b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUiState.kt index a029bb63..4d5b0497 100644 --- a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUiState.kt +++ b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUiState.kt @@ -34,8 +34,9 @@ sealed interface LibrarySideEffect { sealed interface LibraryUiEvent : CircuitUiEvent { data object InitSideEffect : LibraryUiEvent + data object OnLibrarySearchClick : LibraryUiEvent data object OnSettingsClick : LibraryUiEvent - data class OnBookClick(val isbn: String) : LibraryUiEvent + data class OnBookClick(val isbn13: String) : LibraryUiEvent data object OnLoadMore : LibraryUiEvent data object OnRetryClick : LibraryUiEvent data class OnFilterClick(val filterOption: LibraryFilterOption) : LibraryUiEvent diff --git a/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/Screens.kt b/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/Screens.kt index 19ca1ce2..c387543d 100644 --- a/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/Screens.kt +++ b/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/Screens.kt @@ -23,6 +23,9 @@ data object LoginScreen : ReedScreen(name = "Login()") @Parcelize data object SearchScreen : ReedScreen(name = "Search()") +@Parcelize +data object LibrarySearchScreen : ReedScreen(name = "LibrarySearch()") + @Parcelize data object TermsAgreementScreen : ReedScreen(name = "TermsAgreement()") @@ -51,7 +54,7 @@ data class WebViewScreen( ) : ReedScreen(name = "WebView()") @Parcelize -data class BookDetailScreen(val isbn: String) : ReedScreen(name = "BookDetail()") +data class BookDetailScreen(val isbn13: String) : ReedScreen(name = "BookDetail()") @Parcelize data object OnboardingScreen : ReedScreen(name = "Onboarding()") diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/SearchPresenter.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchPresenter.kt similarity index 77% rename from feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/SearchPresenter.kt rename to feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchPresenter.kt index 1e1a0007..14d5c2c3 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/SearchPresenter.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchPresenter.kt @@ -1,4 +1,4 @@ -package com.ninecraft.booket.feature.search +package com.ninecraft.booket.feature.search.book import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.rememberTextFieldState @@ -33,22 +33,22 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch -class SearchPresenter @AssistedInject constructor( +class BookSearchPresenter @AssistedInject constructor( @Assisted private val navigator: Navigator, private val repository: BookRepository, -) : Presenter { +) : Presenter { companion object { private const val PAGE_SIZE = 20 private const val START_INDEX = 1 } @Composable - override fun present(): SearchUiState { + override fun present(): BookSearchUiState { val scope = rememberCoroutineScope() var uiState by rememberRetained { mutableStateOf(UiState.Idle) } var footerState by rememberRetained { mutableStateOf(FooterState.Idle) } val queryState = rememberTextFieldState() - val recentSearches by repository.recentSearches.collectAsRetainedState(initial = emptyList()) + val recentSearches by repository.bookRecentSearches.collectAsRetainedState(initial = emptyList()) var searchResult by rememberRetained { mutableStateOf(BookSearchModel()) } var books by rememberRetained { mutableStateOf(persistentListOf()) } var currentStartIndex by rememberRetained { mutableIntStateOf(START_INDEX) } @@ -58,7 +58,7 @@ class SearchPresenter @AssistedInject constructor( var isBookRegisterBottomSheetVisible by rememberRetained { mutableStateOf(false) } var selectedBookStatus by rememberRetained { mutableStateOf(null) } var isBookRegisterSuccessBottomSheetVisible by rememberRetained { mutableStateOf(false) } - var sideEffect by rememberRetained { mutableStateOf(null) } + var sideEffect by rememberRetained { mutableStateOf(null) } fun searchBooks(query: String, startIndex: Int = START_INDEX) { scope.launch { @@ -104,7 +104,7 @@ class SearchPresenter @AssistedInject constructor( .onSuccess { registeredUserBookId = it.userBookId books = books.map { book -> - if (book.isbn == selectedBookIsbn) { + if (book.isbn13 == selectedBookIsbn) { book.copy(userBookStatus = bookStatus) } else book }.toPersistentList() @@ -117,7 +117,7 @@ class SearchPresenter @AssistedInject constructor( .onFailure { exception -> val handleErrorMessage = { message: String -> Logger.e(message) - sideEffect = SearchSideEffect.ShowToast(message) + sideEffect = BookSearchSideEffect.ShowToast(message) } handleException( @@ -131,79 +131,88 @@ class SearchPresenter @AssistedInject constructor( } } - fun handleEvent(event: SearchUiEvent) { + fun handleEvent(event: BookSearchUiEvent) { when (event) { - is SearchUiEvent.OnBackClick -> { + is BookSearchUiEvent.OnBackClick -> { navigator.pop() } - is SearchUiEvent.OnRecentSearchClick -> { + is BookSearchUiEvent.OnRecentSearchClick -> { + queryState.edit { + replace(0, length, "") + append(event.query) + } searchBooks(query = event.query, startIndex = START_INDEX) } - is SearchUiEvent.OnRecentSearchRemoveClick -> { + is BookSearchUiEvent.OnRecentSearchRemoveClick -> { scope.launch { - repository.removeRecentSearch(query = event.query) + repository.removeBookRecentSearch(query = event.query) } } - is SearchUiEvent.OnSearchClick -> { - searchBooks(query = event.text, startIndex = START_INDEX) + is BookSearchUiEvent.OnSearchClick -> { + val query = event.text.trim() + if (query.isNotEmpty()) { + searchBooks(query = query, startIndex = START_INDEX) + } } - is SearchUiEvent.OnClearClick -> { + is BookSearchUiEvent.OnClearClick -> { queryState.clearText() } - is SearchUiEvent.OnLoadMore -> { - if (footerState !is FooterState.Loading && !isLastPage && queryState.text.toString().isNotEmpty()) { - searchBooks(query = queryState.text.toString(), startIndex = currentStartIndex + 1) + is BookSearchUiEvent.OnLoadMore -> { + val query = queryState.text.trim().toString() + if (footerState !is FooterState.Loading && !isLastPage && query.isNotEmpty()) { + searchBooks(query = query, startIndex = currentStartIndex + 1) } } - is SearchUiEvent.OnRetryClick -> { - if (queryState.text.toString().isNotEmpty()) { - searchBooks(query = queryState.text.toString(), startIndex = START_INDEX) + is BookSearchUiEvent.OnRetryClick -> { + val query = queryState.text.trim().toString() + if (query.isNotEmpty()) { + searchBooks(query = query, startIndex = START_INDEX) } } - is SearchUiEvent.OnBookClick -> { + is BookSearchUiEvent.OnBookClick -> { selectedBookIsbn = event.bookIsbn isBookRegisterBottomSheetVisible = true } - is SearchUiEvent.OnBookRegisterBottomSheetDismiss -> { + is BookSearchUiEvent.OnBookRegisterBottomSheetDismiss -> { isBookRegisterBottomSheetVisible = false selectedBookIsbn = "" selectedBookStatus = null } - is SearchUiEvent.OnBookStatusSelect -> { + is BookSearchUiEvent.OnBookStatusSelect -> { selectedBookStatus = event.bookStatus } - is SearchUiEvent.OnBookRegisterButtonClick -> { + is BookSearchUiEvent.OnBookRegisterButtonClick -> { selectedBookStatus?.let { bookStatus -> upsertBook(selectedBookIsbn, bookStatus.value) } } - is SearchUiEvent.OnBookRegisterSuccessBottomSheetDismiss -> { + is BookSearchUiEvent.OnBookRegisterSuccessBottomSheetDismiss -> { isBookRegisterSuccessBottomSheetVisible = false } - is SearchUiEvent.OnBookRegisterSuccessOkButtonClick -> { + is BookSearchUiEvent.OnBookRegisterSuccessOkButtonClick -> { isBookRegisterSuccessBottomSheetVisible = false scope.launch { navigator.delayedGoTo(RecordScreen(registeredUserBookId)) } } - is SearchUiEvent.OnBookRegisterSuccessCancelButtonClick -> { + is BookSearchUiEvent.OnBookRegisterSuccessCancelButtonClick -> { isBookRegisterSuccessBottomSheetVisible = false } } } - return SearchUiState( + return BookSearchUiState( uiState = uiState, footerState = footerState, queryState = queryState, @@ -224,6 +233,6 @@ class SearchPresenter @AssistedInject constructor( @CircuitInject(SearchScreen::class, ActivityRetainedComponent::class) @AssistedFactory fun interface Factory { - fun create(navigator: Navigator): SearchPresenter + fun create(navigator: Navigator): BookSearchPresenter } } diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/SearchUi.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchUi.kt similarity index 81% rename from feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/SearchUi.kt rename to feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchUi.kt index 29b372ad..0f9f2f7f 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/SearchUi.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchUi.kt @@ -1,5 +1,6 @@ -package com.ninecraft.booket.feature.search +package com.ninecraft.booket.feature.search.book +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -34,11 +35,12 @@ import com.ninecraft.booket.core.ui.component.InfinityLazyColumn import com.ninecraft.booket.core.ui.component.LoadStateFooter import com.ninecraft.booket.core.ui.component.ReedFullScreen import com.ninecraft.booket.feature.screens.SearchScreen -import com.ninecraft.booket.feature.search.component.BookItem -import com.ninecraft.booket.feature.search.component.BookRegisterBottomSheet -import com.ninecraft.booket.feature.search.component.BookRegisterSuccessBottomSheet -import com.ninecraft.booket.feature.search.component.RecentSearchTitle -import com.ninecraft.booket.feature.search.component.SearchItem +import com.ninecraft.booket.feature.search.R +import com.ninecraft.booket.feature.search.book.component.BookItem +import com.ninecraft.booket.feature.search.book.component.BookRegisterBottomSheet +import com.ninecraft.booket.feature.search.book.component.BookRegisterSuccessBottomSheet +import com.ninecraft.booket.feature.search.common.component.RecentSearchTitle +import com.ninecraft.booket.feature.search.common.component.SearchItem import com.slack.circuit.codegen.annotations.CircuitInject import dagger.hilt.android.components.ActivityRetainedComponent import kotlinx.collections.immutable.toImmutableList @@ -48,16 +50,16 @@ import com.ninecraft.booket.core.designsystem.R as designR @CircuitInject(SearchScreen::class, ActivityRetainedComponent::class) @Composable internal fun SearchUi( - state: SearchUiState, + state: BookSearchUiState, modifier: Modifier = Modifier, ) { - HandleSearchSideEffects(state = state) + HandleBookSearchSideEffects(state = state) ReedFullScreen(modifier = modifier) { ReedBackTopAppBar( title = stringResource(R.string.search_title), onBackClick = { - state.eventSink(SearchUiEvent.OnBackClick) + state.eventSink(BookSearchUiEvent.OnBackClick) }, ) SearchContent( @@ -70,7 +72,7 @@ internal fun SearchUi( @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun SearchContent( - state: SearchUiState, + state: BookSearchUiState, modifier: Modifier = Modifier, ) { val bookRegisterBottomSheetState = rememberModalBottomSheetState() @@ -87,12 +89,14 @@ internal fun SearchContent( queryState = state.queryState, queryHintRes = designR.string.search_book_hint, onSearch = { text -> - state.eventSink(SearchUiEvent.OnSearchClick(text)) + state.eventSink(BookSearchUiEvent.OnSearchClick(text)) }, onClear = { - state.eventSink(SearchUiEvent.OnClearClick) + state.eventSink(BookSearchUiEvent.OnClearClick) }, modifier = modifier.padding(horizontal = ReedTheme.spacing.spacing5), + borderStroke = BorderStroke(width = 1.dp, color = ReedTheme.colors.borderBrand), + searchIconTint = ReedTheme.colors.contentBrand, ) Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing5)) @@ -125,7 +129,7 @@ internal fun SearchContent( style = ReedTheme.typography.body1Regular, ) Button( - onClick = { state.eventSink(SearchUiEvent.OnRetryClick) }, + onClick = { state.eventSink(BookSearchUiEvent.OnRetryClick) }, modifier = Modifier.padding(top = ReedTheme.spacing.spacing3), ) { Text(text = stringResource(R.string.retry)) @@ -161,11 +165,11 @@ internal fun SearchContent( SearchItem( query = state.recentSearches[index], onQueryClick = { keyword -> - state.eventSink(SearchUiEvent.OnRecentSearchClick(keyword)) + state.eventSink(BookSearchUiEvent.OnRecentSearchClick(keyword)) }, onRemoveIconClick = { keyword -> state.eventSink( - SearchUiEvent.OnRecentSearchRemoveClick(keyword), + BookSearchUiEvent.OnRecentSearchRemoveClick(keyword), ) }, ) @@ -220,20 +224,20 @@ internal fun SearchContent( InfinityLazyColumn( loadMore = { - state.eventSink(SearchUiEvent.OnLoadMore) + state.eventSink(BookSearchUiEvent.OnLoadMore) }, ) { items( count = state.books.size, - key = { index -> state.books[index].isbn }, + key = { index -> state.books[index].isbn13 }, ) { index -> Column { BookItem( book = state.books[index], onBookClick = { book -> - state.eventSink(SearchUiEvent.OnBookClick(book.isbn)) + state.eventSink(BookSearchUiEvent.OnBookClick(book.isbn13)) }, - enabled = SearchBookStatus.from(state.books[index].userBookStatus) == SearchBookStatus.BEFORE_REGISTRATION, + enabled = BookRegisteredState.from(state.books[index].userBookStatus) == BookRegisteredState.BEFORE_REGISTRATION, ) HorizontalDivider( modifier = Modifier.fillMaxWidth(), @@ -246,7 +250,7 @@ internal fun SearchContent( item { LoadStateFooter( footerState = state.footerState, - onRetryClick = { state.eventSink(SearchUiEvent.OnLoadMore) }, + onRetryClick = { state.eventSink(BookSearchUiEvent.OnLoadMore) }, ) } } @@ -256,36 +260,36 @@ internal fun SearchContent( if (state.isBookRegisterBottomSheetVisible) { BookRegisterBottomSheet( - onDismissRequest = { state.eventSink(SearchUiEvent.OnBookRegisterBottomSheetDismiss) }, + onDismissRequest = { state.eventSink(BookSearchUiEvent.OnBookRegisterBottomSheetDismiss) }, sheetState = bookRegisterBottomSheetState, onCloseButtonClick = { coroutineScope.launch { bookRegisterBottomSheetState.hide() - state.eventSink(SearchUiEvent.OnBookRegisterBottomSheetDismiss) + state.eventSink(BookSearchUiEvent.OnBookRegisterBottomSheetDismiss) } }, bookStatuses = BookStatus.entries.toTypedArray().toImmutableList(), currentBookStatus = state.selectedBookStatus, onItemSelected = { bookStatus -> state.eventSink( - SearchUiEvent.OnBookStatusSelect(bookStatus), + BookSearchUiEvent.OnBookStatusSelect(bookStatus), ) }, - onBookRegisterButtonClick = { state.eventSink(SearchUiEvent.OnBookRegisterButtonClick) }, + onBookRegisterButtonClick = { state.eventSink(BookSearchUiEvent.OnBookRegisterButtonClick) }, ) } if (state.isBookRegisterSuccessBottomSheetVisible) { BookRegisterSuccessBottomSheet( - onDismissRequest = { state.eventSink(SearchUiEvent.OnBookRegisterSuccessBottomSheetDismiss) }, + onDismissRequest = { state.eventSink(BookSearchUiEvent.OnBookRegisterSuccessBottomSheetDismiss) }, sheetState = bookRegisterSuccessBottomSheetState, onCancelButtonClick = { coroutineScope.launch { bookRegisterSuccessBottomSheetState.hide() - state.eventSink(SearchUiEvent.OnBookRegisterSuccessBottomSheetDismiss) + state.eventSink(BookSearchUiEvent.OnBookRegisterSuccessBottomSheetDismiss) } }, - onOKButtonClick = { state.eventSink(SearchUiEvent.OnBookRegisterSuccessOkButtonClick) }, + onOKButtonClick = { state.eventSink(BookSearchUiEvent.OnBookRegisterSuccessOkButtonClick) }, ) } } @@ -296,7 +300,7 @@ internal fun SearchContent( private fun SearchPreview() { ReedTheme { SearchUi( - state = SearchUiState( + state = BookSearchUiState( eventSink = {}, ), ) diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/SearchUiState.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchUiState.kt similarity index 60% rename from feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/SearchUiState.kt rename to feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchUiState.kt index 784832c9..84d3df88 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/SearchUiState.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchUiState.kt @@ -1,4 +1,4 @@ -package com.ninecraft.booket.feature.search +package com.ninecraft.booket.feature.search.book import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Immutable @@ -19,7 +19,7 @@ sealed interface UiState { data class Error(val message: String) : UiState } -data class SearchUiState( +data class BookSearchUiState( val uiState: UiState = UiState.Idle, val footerState: FooterState = FooterState.Idle, val queryState: TextFieldState = TextFieldState(), @@ -32,43 +32,43 @@ data class SearchUiState( val isBookRegisterBottomSheetVisible: Boolean = false, val selectedBookStatus: BookStatus? = null, val isBookRegisterSuccessBottomSheetVisible: Boolean = false, - val sideEffect: SearchSideEffect? = null, - val eventSink: (SearchUiEvent) -> Unit, + val sideEffect: BookSearchSideEffect? = null, + val eventSink: (BookSearchUiEvent) -> Unit, ) : CircuitUiState { val isEmptySearchResult: Boolean get() = uiState is UiState.Success && searchResult.totalResults == 0 } @Immutable -sealed interface SearchSideEffect { +sealed interface BookSearchSideEffect { data class ShowToast( val message: String, private val key: String = UUID.randomUUID().toString(), - ) : SearchSideEffect + ) : BookSearchSideEffect } -sealed interface SearchUiEvent : CircuitUiEvent { - data object OnBackClick : SearchUiEvent - data class OnRecentSearchClick(val query: String) : SearchUiEvent - data class OnRecentSearchRemoveClick(val query: String) : SearchUiEvent - data class OnSearchClick(val text: String) : SearchUiEvent - data object OnClearClick : SearchUiEvent - data class OnBookClick(val bookIsbn: String) : SearchUiEvent - data object OnLoadMore : SearchUiEvent - data object OnRetryClick : SearchUiEvent - data object OnBookRegisterBottomSheetDismiss : SearchUiEvent - data class OnBookStatusSelect(val bookStatus: BookStatus) : SearchUiEvent - data object OnBookRegisterSuccessBottomSheetDismiss : SearchUiEvent - data object OnBookRegisterButtonClick : SearchUiEvent - data object OnBookRegisterSuccessOkButtonClick : SearchUiEvent - data object OnBookRegisterSuccessCancelButtonClick : SearchUiEvent +sealed interface BookSearchUiEvent : CircuitUiEvent { + data object OnBackClick : BookSearchUiEvent + data class OnRecentSearchClick(val query: String) : BookSearchUiEvent + data class OnRecentSearchRemoveClick(val query: String) : BookSearchUiEvent + data class OnSearchClick(val text: String) : BookSearchUiEvent + data object OnClearClick : BookSearchUiEvent + data class OnBookClick(val bookIsbn: String) : BookSearchUiEvent + data object OnLoadMore : BookSearchUiEvent + data object OnRetryClick : BookSearchUiEvent + data object OnBookRegisterBottomSheetDismiss : BookSearchUiEvent + data class OnBookStatusSelect(val bookStatus: BookStatus) : BookSearchUiEvent + data object OnBookRegisterSuccessBottomSheetDismiss : BookSearchUiEvent + data object OnBookRegisterButtonClick : BookSearchUiEvent + data object OnBookRegisterSuccessOkButtonClick : BookSearchUiEvent + data object OnBookRegisterSuccessCancelButtonClick : BookSearchUiEvent } -enum class SearchBookStatus(val value: String) { +enum class BookRegisteredState(val value: String) { BEFORE_REGISTRATION("BEFORE_REGISTRATION"), ; companion object { - fun from(value: String?): SearchBookStatus? { + fun from(value: String?): BookRegisteredState? { return entries.find { it.value == value } } } diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/HandlingSearchSideEffect.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/HandlingBookSearchSideEffect.kt similarity index 72% rename from feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/HandlingSearchSideEffect.kt rename to feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/HandlingBookSearchSideEffect.kt index 659bdb1b..7249bf51 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/HandlingSearchSideEffect.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/HandlingBookSearchSideEffect.kt @@ -1,4 +1,4 @@ -package com.ninecraft.booket.feature.search +package com.ninecraft.booket.feature.search.book import android.widget.Toast import androidx.compose.runtime.Composable @@ -6,14 +6,14 @@ import androidx.compose.ui.platform.LocalContext import com.skydoves.compose.effects.RememberedEffect @Composable -internal fun HandleSearchSideEffects( - state: SearchUiState, +internal fun HandleBookSearchSideEffects( + state: BookSearchUiState, ) { val context = LocalContext.current RememberedEffect(state.sideEffect) { when (state.sideEffect) { - is SearchSideEffect.ShowToast -> { + is BookSearchSideEffect.ShowToast -> { Toast.makeText(context, state.sideEffect.message, Toast.LENGTH_SHORT).show() } diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/BookItem.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/component/BookItem.kt similarity index 98% rename from feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/BookItem.kt rename to feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/component/BookItem.kt index d9e70a13..f5c4d63c 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/BookItem.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/component/BookItem.kt @@ -1,4 +1,4 @@ -package com.ninecraft.booket.feature.search.component +package com.ninecraft.booket.feature.search.book.component import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -139,7 +139,7 @@ private fun BookItemPreview() { author = "마쓰이에 마사시 마쓰이에 마사시", publisher = "비채", coverImageUrl = "https://example.com/sample-book-cover.jpg", - isbn = "", + isbn13 = "", ), onBookClick = {}, ) diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/BookRegisterBottomSheet.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/component/BookRegisterBottomSheet.kt similarity index 99% rename from feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/BookRegisterBottomSheet.kt rename to feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/component/BookRegisterBottomSheet.kt index e7f2ab43..ada10a33 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/BookRegisterBottomSheet.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/component/BookRegisterBottomSheet.kt @@ -1,4 +1,4 @@ -package com.ninecraft.booket.feature.search.component +package com.ninecraft.booket.feature.search.book.component import androidx.compose.foundation.background import androidx.compose.foundation.clickable diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/BookRegisterSuccessBottomSheet.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/component/BookRegisterSuccessBottomSheet.kt similarity index 98% rename from feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/BookRegisterSuccessBottomSheet.kt rename to feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/component/BookRegisterSuccessBottomSheet.kt index 696d65d8..cfc3cb29 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/BookRegisterSuccessBottomSheet.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/component/BookRegisterSuccessBottomSheet.kt @@ -1,4 +1,4 @@ -package com.ninecraft.booket.feature.search.component +package com.ninecraft.booket.feature.search.book.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/RecentSearchTitle.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/common/component/RecentSearchTitle.kt similarity index 95% rename from feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/RecentSearchTitle.kt rename to feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/common/component/RecentSearchTitle.kt index dbae2f45..9fe2fda7 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/RecentSearchTitle.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/common/component/RecentSearchTitle.kt @@ -1,4 +1,4 @@ -package com.ninecraft.booket.feature.search.component +package com.ninecraft.booket.feature.search.common.component import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/SearchItem.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/common/component/SearchItem.kt similarity index 97% rename from feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/SearchItem.kt rename to feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/common/component/SearchItem.kt index d411c63f..0a435282 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/SearchItem.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/common/component/SearchItem.kt @@ -1,4 +1,4 @@ -package com.ninecraft.booket.feature.search.component +package com.ninecraft.booket.feature.search.common.component import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/HandlingLibrarySearchSideEffect.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/HandlingLibrarySearchSideEffect.kt new file mode 100644 index 00000000..cfc3508f --- /dev/null +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/HandlingLibrarySearchSideEffect.kt @@ -0,0 +1,23 @@ +package com.ninecraft.booket.feature.search.library + +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import com.skydoves.compose.effects.RememberedEffect + +@Composable +internal fun HandlingLibrarySearchSideEffect( + state: LibrarySearchUiState, +) { + val context = LocalContext.current + + RememberedEffect(state.sideEffect) { + when (state.sideEffect) { + is LibrarySearchSideEffect.ShowToast -> { + Toast.makeText(context, state.sideEffect.message, Toast.LENGTH_SHORT).show() + } + + null -> {} + } + } +} diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchPresenter.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchPresenter.kt new file mode 100644 index 00000000..97f47de1 --- /dev/null +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchPresenter.kt @@ -0,0 +1,176 @@ +package com.ninecraft.booket.feature.search.library + +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import com.ninecraft.booket.core.common.utils.handleException +import com.ninecraft.booket.core.data.api.repository.BookRepository +import com.ninecraft.booket.core.model.LibraryBookSummaryModel +import com.ninecraft.booket.core.ui.component.FooterState +import com.ninecraft.booket.feature.screens.BookDetailScreen +import com.ninecraft.booket.feature.screens.LibrarySearchScreen +import com.ninecraft.booket.feature.screens.LoginScreen +import com.orhanobut.logger.Logger +import com.slack.circuit.codegen.annotations.CircuitInject +import com.slack.circuit.retained.collectAsRetainedState +import com.slack.circuit.retained.rememberRetained +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.presenter.Presenter +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.components.ActivityRetainedComponent +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.launch + +class LibrarySearchPresenter @AssistedInject constructor( + @Assisted private val navigator: Navigator, + private val repository: BookRepository, +) : Presenter { + companion object { + private const val PAGE_SIZE = 20 + private const val START_INDEX = 0 + } + + @Composable + override fun present(): LibrarySearchUiState { + val scope = rememberCoroutineScope() + + var uiState by rememberRetained { mutableStateOf(UiState.Idle) } + var footerState by rememberRetained { mutableStateOf(FooterState.Idle) } + val queryState = rememberTextFieldState() + val recentSearches by repository.libraryRecentSearches.collectAsRetainedState(initial = emptyList()) + var books by rememberRetained { mutableStateOf(persistentListOf()) } + var sideEffect by rememberRetained { mutableStateOf(null) } + + var currentPage by rememberRetained { mutableIntStateOf(START_INDEX) } + var isLastPage by rememberRetained { mutableStateOf(false) } + + fun searchLibraryBooks(query: String, page: Int, size: Int) { + scope.launch { + if (page == START_INDEX) { + uiState = UiState.Loading + } else { + footerState = FooterState.Loading + } + + repository.searchLibraryBooks(title = query, page = page, size = size) + .onSuccess { result -> + books = if (result.books.page.number == START_INDEX) { + result.books.content.toPersistentList() + } else { + (books + result.books.content).toPersistentList() + } + + currentPage = page + isLastPage = result.books.page.number == result.books.page.totalPages - 1 + + if (page == START_INDEX) { + uiState = UiState.Success + footerState = FooterState.Idle + } else { + footerState = if (isLastPage) FooterState.End else FooterState.Idle + } + } + .onFailure { exception -> + val errorMessage = exception.message ?: "알 수 없는 오류가 발생했습니다." + if (page == START_INDEX) { + uiState = UiState.Error(errorMessage) + } else { + footerState = FooterState.Error(errorMessage) + } + + val handleErrorMessage = { message: String -> + Logger.e(message) + sideEffect = LibrarySearchSideEffect.ShowToast(message) + } + + handleException( + exception = exception, + onError = handleErrorMessage, + onLoginRequired = { + navigator.resetRoot(LoginScreen) + }, + ) + } + } + } + + fun handleEvent(event: LibrarySearchUiEvent) { + when (event) { + is LibrarySearchUiEvent.OnBackClick -> { + navigator.pop() + } + + is LibrarySearchUiEvent.OnRecentSearchClick -> { + queryState.edit { + replace(0, length, "") + append(event.query) + } + searchLibraryBooks(query = event.query, page = START_INDEX, size = PAGE_SIZE) + } + + is LibrarySearchUiEvent.OnRecentSearchRemoveClick -> { + scope.launch { + repository.removeLibraryRecentSearch(event.query) + } + } + + is LibrarySearchUiEvent.OnSearchClick -> { + val query = event.query.trim() + if (query.isNotEmpty()) { + searchLibraryBooks(query = query, page = START_INDEX, size = PAGE_SIZE) + } + } + + is LibrarySearchUiEvent.OnClearClick -> { + queryState.clearText() + } + + is LibrarySearchUiEvent.OnLoadMore -> { + val query = queryState.text.trim().toString() + if (footerState !is FooterState.Loading && !isLastPage && query.isNotEmpty()) { + searchLibraryBooks(query = query, page = currentPage + 1, size = PAGE_SIZE) + } + } + + is LibrarySearchUiEvent.OnRetryClick -> { + val query = queryState.text.trim().toString() + if (query.isNotEmpty()) { + searchLibraryBooks(query = query, page = START_INDEX, size = PAGE_SIZE) + } + } + + is LibrarySearchUiEvent.OnBookClick -> { + val userBookId = event.userBookId + val isbn13 = event.isbn13 + // TODO: 도서 상세 화면에 isbn13, userBookId 넘겨야 함 + navigator.goTo(BookDetailScreen(isbn13 = isbn13)) + } + } + } + + return LibrarySearchUiState( + uiState = uiState, + footerState = footerState, + queryState = queryState, + recentSearches = recentSearches.toImmutableList(), + books = books, + sideEffect = sideEffect, + eventSink = ::handleEvent, + ) + } + + @CircuitInject(LibrarySearchScreen::class, ActivityRetainedComponent::class) + @AssistedFactory + fun interface Factory { + fun create(navigator: Navigator): LibrarySearchPresenter + } +} diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchUi.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchUi.kt new file mode 100644 index 00000000..3ccfa9a7 --- /dev/null +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchUi.kt @@ -0,0 +1,232 @@ +package com.ninecraft.booket.feature.search.library + +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.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.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.ninecraft.booket.core.designsystem.DevicePreview +import com.ninecraft.booket.core.designsystem.component.ReedDivider +import com.ninecraft.booket.core.designsystem.component.textfield.ReedTextField +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.designsystem.theme.White +import com.ninecraft.booket.core.ui.component.InfinityLazyColumn +import com.ninecraft.booket.core.ui.component.LoadStateFooter +import com.ninecraft.booket.core.ui.component.ReedBackTopAppBar +import com.ninecraft.booket.core.ui.component.ReedFullScreen +import com.ninecraft.booket.feature.screens.LibrarySearchScreen +import com.ninecraft.booket.feature.search.R +import com.ninecraft.booket.feature.search.common.component.RecentSearchTitle +import com.ninecraft.booket.feature.search.common.component.SearchItem +import com.ninecraft.booket.feature.search.library.component.LibraryBookItem +import com.slack.circuit.codegen.annotations.CircuitInject +import dagger.hilt.android.components.ActivityRetainedComponent + +@CircuitInject(LibrarySearchScreen::class, ActivityRetainedComponent::class) +@Composable +internal fun LibrarySearchUi( + state: LibrarySearchUiState, + modifier: Modifier = Modifier, +) { + HandlingLibrarySearchSideEffect(state = state) + + ReedFullScreen(modifier = modifier) { + ReedBackTopAppBar( + title = stringResource(R.string.library_search_title), + onBackClick = { + state.eventSink(LibrarySearchUiEvent.OnBackClick) + }, + ) + LibrarySearchContent( + state = state, + modifier = modifier, + ) + } +} + +@Composable +internal fun LibrarySearchContent( + state: LibrarySearchUiState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .background(White), + ) { + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing3)) + ReedTextField( + queryState = state.queryState, + queryHintRes = R.string.library_search_hint, + onSearch = { text -> + state.eventSink(LibrarySearchUiEvent.OnSearchClick(text)) + }, + onClear = { + state.eventSink(LibrarySearchUiEvent.OnClearClick) + }, + modifier = modifier.padding(horizontal = ReedTheme.spacing.spacing5), + backgroundColor = ReedTheme.colors.baseSecondary, + borderStroke = null, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing5)) + ReedDivider( + modifier = Modifier + .fillMaxWidth() + .height(ReedTheme.spacing.spacing2), + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) + + when (state.uiState) { + is UiState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = ReedTheme.colors.contentBrand) + } + } + + is UiState.Error -> { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = state.uiState.message, + style = ReedTheme.typography.body1Regular, + ) + Button( + onClick = { state.eventSink(LibrarySearchUiEvent.OnRetryClick) }, + modifier = Modifier.padding(top = ReedTheme.spacing.spacing3), + ) { + Text(text = stringResource(R.string.retry)) + } + } + } + + is UiState.Idle -> { + if (state.recentSearches.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + RecentSearchTitle(modifier = Modifier.align(Alignment.TopCenter)) + Text( + text = stringResource(R.string.empty_recent_searches), + modifier = Modifier.align(Alignment.Center), + color = ReedTheme.colors.contentSecondary, + style = ReedTheme.typography.body1Medium, + ) + } + } else { + LazyColumn { + item { + RecentSearchTitle() + } + + items( + count = state.recentSearches.size, + key = { index -> state.recentSearches[index] }, + ) { index -> + Column { + SearchItem( + query = state.recentSearches[index], + onQueryClick = { keyword -> + state.eventSink(LibrarySearchUiEvent.OnRecentSearchClick(keyword)) + }, + onRemoveIconClick = { keyword -> + state.eventSink(LibrarySearchUiEvent.OnRecentSearchRemoveClick(keyword)) + }, + ) + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = 1.dp, + color = ReedTheme.colors.borderPrimary, + ) + } + } + } + } + } + + is UiState.Success -> { + if (state.books.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.library_empty_result), + color = ReedTheme.colors.contentSecondary, + style = ReedTheme.typography.body1Medium, + ) + } + } else { + InfinityLazyColumn( + loadMore = { + state.eventSink(LibrarySearchUiEvent.OnLoadMore) + }, + ) { + items( + count = state.books.size, + key = { index -> state.books[index].bookIsbn }, + ) { index -> + Column { + LibraryBookItem( + book = state.books[index], + onBookClick = { book -> + state.eventSink( + LibrarySearchUiEvent.OnBookClick( + userBookId = book.userBookId, + isbn13 = book.bookIsbn, + ), + ) + }, + ) + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = 1.dp, + color = ReedTheme.colors.borderPrimary, + ) + } + } + + item { + LoadStateFooter( + footerState = state.footerState, + onRetryClick = { state.eventSink(LibrarySearchUiEvent.OnLoadMore) }, + ) + } + } + } + } + } + } +} + +@DevicePreview +@Composable +private fun LibrarySearchPreview() { + ReedTheme { + LibrarySearchUi( + state = LibrarySearchUiState( + eventSink = {}, + ), + ) + } +} diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchUiState.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchUiState.kt new file mode 100644 index 00000000..121a0687 --- /dev/null +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchUiState.kt @@ -0,0 +1,45 @@ +package com.ninecraft.booket.feature.search.library + +import androidx.compose.foundation.text.input.TextFieldState +import com.ninecraft.booket.core.model.LibraryBookSummaryModel +import com.ninecraft.booket.core.ui.component.FooterState +import com.slack.circuit.runtime.CircuitUiEvent +import com.slack.circuit.runtime.CircuitUiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import java.util.UUID + +sealed interface UiState { + data object Idle : UiState + data object Loading : UiState + data object Success : UiState + data class Error(val message: String) : UiState +} + +data class LibrarySearchUiState( + val uiState: UiState = UiState.Idle, + val footerState: FooterState = FooterState.Idle, + val queryState: TextFieldState = TextFieldState(), + val recentSearches: ImmutableList = persistentListOf(), + val books: ImmutableList = persistentListOf(), + val sideEffect: LibrarySearchSideEffect? = null, + val eventSink: (LibrarySearchUiEvent) -> Unit, +) : CircuitUiState + +sealed interface LibrarySearchSideEffect { + data class ShowToast( + val message: String, + private val key: String = UUID.randomUUID().toString(), + ) : LibrarySearchSideEffect +} + +sealed interface LibrarySearchUiEvent : CircuitUiEvent { + data object OnBackClick : LibrarySearchUiEvent + data class OnRecentSearchClick(val query: String) : LibrarySearchUiEvent + data class OnRecentSearchRemoveClick(val query: String) : LibrarySearchUiEvent + data class OnSearchClick(val query: String) : LibrarySearchUiEvent + data object OnClearClick : LibrarySearchUiEvent + data object OnLoadMore : LibrarySearchUiEvent + data object OnRetryClick : LibrarySearchUiEvent + data class OnBookClick(val userBookId: String, val isbn13: String) : LibrarySearchUiEvent +} diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/component/LibraryBookItem.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/component/LibraryBookItem.kt new file mode 100644 index 00000000..f019c57a --- /dev/null +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/component/LibraryBookItem.kt @@ -0,0 +1,126 @@ +package com.ninecraft.booket.feature.search.library.component + +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.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.ninecraft.booket.core.common.extensions.clickableSingle +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.component.NetworkImage +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.model.LibraryBookSummaryModel +import com.ninecraft.booket.feature.search.R +import com.ninecraft.booket.core.designsystem.R as designR + +@Composable +fun LibraryBookItem( + book: LibraryBookSummaryModel, + onBookClick: (LibraryBookSummaryModel) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickableSingle { onBookClick(book) } + .padding(horizontal = ReedTheme.spacing.spacing5), + verticalAlignment = Alignment.CenterVertically, + ) { + NetworkImage( + imageUrl = book.coverImageUrl, + contentDescription = "Book CoverImage", + modifier = Modifier + .padding( + top = ReedTheme.spacing.spacing4, + end = ReedTheme.spacing.spacing4, + bottom = ReedTheme.spacing.spacing4, + ) + .width(68.dp) + .height(100.dp) + .clip(RoundedCornerShape(size = ReedTheme.radius.sm)), + placeholder = painterResource(designR.drawable.ic_placeholder), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = book.bookTitle, + color = ReedTheme.colors.contentPrimary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = ReedTheme.typography.body1SemiBold, + ) + Spacer(Modifier.height(ReedTheme.spacing.spacing1)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = book.bookAuthor, + color = ReedTheme.colors.contentTertiary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = ReedTheme.typography.label1Medium, + modifier = Modifier.weight(0.7f, fill = false), + ) + Spacer(Modifier.width(ReedTheme.spacing.spacing1)) + VerticalDivider( + modifier = Modifier.height(14.dp), + thickness = 1.dp, + color = ReedTheme.colors.contentTertiary, + ) + Spacer(Modifier.width(ReedTheme.spacing.spacing1)) + Text( + text = book.publisher, + color = ReedTheme.colors.contentTertiary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = ReedTheme.typography.label1Medium, + modifier = Modifier.weight(0.3f, fill = false), + ) + } + Spacer(Modifier.height(ReedTheme.spacing.spacing4)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.library_book_item_records), + color = ReedTheme.colors.contentPrimary, + style = ReedTheme.typography.label2Regular, + ) + Spacer(Modifier.width(ReedTheme.spacing.spacing1)) + Text( + text = book.recordCount.toString(), + color = ReedTheme.colors.contentBrand, + style = ReedTheme.typography.label2SemiBold, + ) + } + } + } +} + +@ComponentPreview +@Composable +private fun LibraryBookItemPreview() { + ReedTheme { + LibraryBookItem( + book = LibraryBookSummaryModel( + bookTitle = "여름은 오래 그곳에 남아", + bookAuthor = "마쓰이에 마사시 마쓰이에 마사시", + publisher = "비채", + coverImageUrl = "https://example.com/sample-book-cover.jpg", + recordCount = 3, + ), + onBookClick = {}, + ) + } +} diff --git a/feature/search/src/main/res/values/strings.xml b/feature/search/src/main/res/values/strings.xml index cf16e640..62643e74 100644 --- a/feature/search/src/main/res/values/strings.xml +++ b/feature/search/src/main/res/values/strings.xml @@ -15,4 +15,8 @@ 도서 등록 최근 검색어 내역이 없습니다. 이미 등록된 책입니다 + 내 서재 검색 + 등록한 책을 검색해보세요 + 남긴 기록 + 내 서재에 해당 도서가 없습니다.