diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f87f0f070..7fe2bf5cd 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -93,6 +93,7 @@ dependencies {
implementation(projects.feature.signin)
implementation(projects.feature.library)
implementation(projects.feature.feed)
+ implementation(projects.feature.collection)
// AndroidX 및 Jetpack 기본 라이브러리
implementation(libs.androidx.core.ktx)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a67f2cedf..2446f0f1a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -76,6 +76,10 @@
android:name=".ui.main.MainActivity"
android:exported="false"
android:screenOrientation="portrait" />
+
(fragment_my_page) {
setupObserver()
onProfileEditClick()
onStorageButtonClick()
+ setupCollectionEntry()
tracker.trackEvent("mypage")
}
@@ -92,6 +97,23 @@ class MyPageFragment : BaseFragment(fragment_my_page) {
binding.lifecycleOwner = viewLifecycleOwner
}
+ private fun setupCollectionEntry() {
+ binding.cvMyPageCollectionEntry.apply {
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+ setContent {
+ WebsosoTheme {
+ CollectionEntry(onClick = ::navigateToCollection)
+ }
+ }
+ }
+ }
+
+ private fun navigateToCollection() {
+ singleEventHandler.throttleFirst {
+ startActivity(CollectionActivity.getIntent(requireContext()))
+ }
+ }
+
private fun setupRestGenrePreferenceAdapter() {
binding.lvMyLibraryRestGenre.adapter = restGenrePreferenceAdapter
}
diff --git a/app/src/main/res/layout/fragment_my_page.xml b/app/src/main/res/layout/fragment_my_page.xml
index 5eabdef49..39e4f6d61 100644
--- a/app/src/main/res/layout/fragment_my_page.xml
+++ b/app/src/main/res/layout/fragment_my_page.xml
@@ -279,11 +279,28 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/cl_my_page_user_profile" />
+
+
+
+
+ app:layout_constraintTop_toBottomOf="@id/view_my_page_collection_divider">
+
+
+
+
diff --git a/core/resource/src/main/res/drawable/ic_collection_novel_add.xml b/core/resource/src/main/res/drawable/ic_collection_novel_add.xml
new file mode 100644
index 000000000..32a6762f5
--- /dev/null
+++ b/core/resource/src/main/res/drawable/ic_collection_novel_add.xml
@@ -0,0 +1,12 @@
+
+
+
diff --git a/core/resource/src/main/res/drawable/ic_collection_novel_delete.xml b/core/resource/src/main/res/drawable/ic_collection_novel_delete.xml
new file mode 100644
index 000000000..abccbfe26
--- /dev/null
+++ b/core/resource/src/main/res/drawable/ic_collection_novel_delete.xml
@@ -0,0 +1,12 @@
+
+
+
diff --git a/data/library/src/main/java/com/into/websoso/data/library/LibraryRepository.kt b/data/library/src/main/java/com/into/websoso/data/library/LibraryRepository.kt
index b05e1e793..aa0ca7978 100644
--- a/data/library/src/main/java/com/into/websoso/data/library/LibraryRepository.kt
+++ b/data/library/src/main/java/com/into/websoso/data/library/LibraryRepository.kt
@@ -10,6 +10,8 @@ interface LibraryRepository {
fun getLibraryFlow(): Flow>
+ fun getUnfilteredLibraryFlow(): Flow>
+
suspend fun getRegisteredKeywords(): List
companion object {
diff --git a/data/library/src/main/java/com/into/websoso/data/library/repository/MyLibraryRepository.kt b/data/library/src/main/java/com/into/websoso/data/library/repository/MyLibraryRepository.kt
index 7d58d9cba..3d44e7c0d 100644
--- a/data/library/src/main/java/com/into/websoso/data/library/repository/MyLibraryRepository.kt
+++ b/data/library/src/main/java/com/into/websoso/data/library/repository/MyLibraryRepository.kt
@@ -39,25 +39,9 @@ class MyLibraryRepository
@OptIn(ExperimentalCoroutinesApi::class)
override fun getLibraryFlow(): Flow> =
filterRepository.filterFlow
- .flatMapLatest { currentFilter ->
- Pager(
- config = PagingConfig(
- pageSize = PAGE_SIZE,
- enablePlaceholders = false,
- ),
- pagingSourceFactory = {
- LibraryPagingSource(
- getNovels = { cursor ->
- getUserNovels(cursor, currentFilter).also { result ->
- _novelTotalCount.update {
- result.getOrNull()?.userNovelCount ?: 0
- }
- }
- },
- )
- },
- ).flow
- }
+ .flatMapLatest(::createLibraryFlow)
+
+ override fun getUnfilteredLibraryFlow(): Flow> = createLibraryFlow(LibraryFilter())
override suspend fun getRegisteredKeywords(): List {
val userId = accountRepository.userIdFlow.first { it != 0L }
@@ -87,6 +71,25 @@ class MyLibraryRepository
)
}
+ private fun createLibraryFlow(libraryFilter: LibraryFilter): Flow> =
+ Pager(
+ config = PagingConfig(
+ pageSize = PAGE_SIZE,
+ enablePlaceholders = false,
+ ),
+ pagingSourceFactory = {
+ LibraryPagingSource(
+ getNovels = { cursor ->
+ getUserNovels(cursor, libraryFilter).also { result ->
+ _novelTotalCount.update {
+ result.getOrNull()?.userNovelCount ?: 0
+ }
+ }
+ },
+ )
+ },
+ ).flow
+
private fun LibraryFilter.toRatingRangeParams(): Pair =
when {
isRatingless -> null to null
diff --git a/data/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.kt b/data/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.kt
index 38f098df9..15cff9dc4 100644
--- a/data/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.kt
+++ b/data/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.kt
@@ -43,25 +43,9 @@ internal class UserLibraryRepository
@OptIn(ExperimentalCoroutinesApi::class)
override fun getLibraryFlow(): Flow> =
filterRepository.filterFlow
- .flatMapLatest { currentFilter ->
- Pager(
- config = PagingConfig(
- pageSize = PAGE_SIZE,
- enablePlaceholders = false,
- ),
- pagingSourceFactory = {
- LibraryPagingSource(
- getNovels = { cursor ->
- getUserNovels(cursor, currentFilter).also { result ->
- _novelTotalCount.update {
- result.getOrNull()?.userNovelCount ?: 0
- }
- }
- },
- )
- },
- ).flow
- }
+ .flatMapLatest(::createLibraryFlow)
+
+ override fun getUnfilteredLibraryFlow(): Flow> = createLibraryFlow(LibraryFilter())
override suspend fun getRegisteredKeywords() = libraryRemoteDataSource.getUserNovelKeywords(userId = userId)
@@ -88,6 +72,25 @@ internal class UserLibraryRepository
)
}
+ private fun createLibraryFlow(libraryFilter: LibraryFilter): Flow> =
+ Pager(
+ config = PagingConfig(
+ pageSize = PAGE_SIZE,
+ enablePlaceholders = false,
+ ),
+ pagingSourceFactory = {
+ LibraryPagingSource(
+ getNovels = { cursor ->
+ getUserNovels(cursor, libraryFilter).also { result ->
+ _novelTotalCount.update {
+ result.getOrNull()?.userNovelCount ?: 0
+ }
+ }
+ },
+ )
+ },
+ ).flow
+
private fun LibraryFilter.toRatingRangeParams(): Pair =
when {
isRatingless -> null to null
diff --git a/data/novel/build.gradle.kts b/data/novel/build.gradle.kts
new file mode 100644
index 000000000..2b6edc225
--- /dev/null
+++ b/data/novel/build.gradle.kts
@@ -0,0 +1,20 @@
+import com.into.websoso.setNamespace
+
+plugins {
+ id("websoso.android.library")
+ alias(libs.plugins.kotlin.serialization)
+}
+
+android {
+ setNamespace("data.novel")
+}
+
+dependencies {
+ implementation(projects.core.network)
+
+ implementation(libs.paging.runtime)
+ implementation(libs.retrofit)
+ implementation(libs.serialization.json)
+
+ testImplementation(libs.junit)
+}
diff --git a/data/novel/src/main/AndroidManifest.xml b/data/novel/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..8072ee00d
--- /dev/null
+++ b/data/novel/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchApi.kt b/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchApi.kt
new file mode 100644
index 000000000..8eeed5e32
--- /dev/null
+++ b/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchApi.kt
@@ -0,0 +1,13 @@
+package com.into.websoso.data.novel
+
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+internal interface NovelSearchApi {
+ @GET("novels")
+ suspend fun getNovels(
+ @Query("query") query: String,
+ @Query("page") page: Int,
+ @Query("size") size: Int,
+ ): NovelSearchResponseDto
+}
diff --git a/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchApiModule.kt b/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchApiModule.kt
new file mode 100644
index 000000000..f20c102a8
--- /dev/null
+++ b/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchApiModule.kt
@@ -0,0 +1,16 @@
+package com.into.websoso.data.novel
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import retrofit2.Retrofit
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal object NovelSearchApiModule {
+ @Provides
+ @Singleton
+ fun provideNovelSearchApi(retrofit: Retrofit): NovelSearchApi = retrofit.create(NovelSearchApi::class.java)
+}
diff --git a/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchPagingSource.kt b/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchPagingSource.kt
new file mode 100644
index 000000000..e1e756bbc
--- /dev/null
+++ b/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchPagingSource.kt
@@ -0,0 +1,37 @@
+package com.into.websoso.data.novel
+
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+import com.into.websoso.data.novel.model.NovelSearchEntity
+
+internal class NovelSearchPagingSource(
+ private val query: String,
+ private val api: NovelSearchApi,
+) : PagingSource() {
+ override suspend fun load(params: LoadParams): LoadResult =
+ runCatching {
+ val page = params.key ?: INITIAL_PAGE
+ val response = api.getNovels(
+ query = query,
+ page = page,
+ size = params.loadSize,
+ )
+
+ LoadResult.Page(
+ data = response.novels.map(NovelSearchResponseDto.NovelDto::toData),
+ prevKey = page.takeIf { it > INITIAL_PAGE }?.minus(1),
+ nextKey = (page + 1).takeIf { response.isLoadable },
+ )
+ }.getOrElse(LoadResult::Error)
+
+ override fun getRefreshKey(state: PagingState): Int? =
+ state.anchorPosition?.let { anchorPosition ->
+ state.closestPageToPosition(anchorPosition)?.let { page ->
+ page.prevKey?.plus(1) ?: page.nextKey?.minus(1)
+ }
+ }
+
+ private companion object {
+ const val INITIAL_PAGE = 0
+ }
+}
diff --git a/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchRepository.kt b/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchRepository.kt
new file mode 100644
index 000000000..bdd085a9a
--- /dev/null
+++ b/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchRepository.kt
@@ -0,0 +1,33 @@
+package com.into.websoso.data.novel
+
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.PagingData
+import com.into.websoso.data.novel.model.NovelSearchEntity
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+
+class NovelSearchRepository
+ @Inject
+ internal constructor(
+ private val api: NovelSearchApi,
+ ) {
+ fun searchNovels(query: String): Flow> =
+ Pager(
+ config = PagingConfig(
+ pageSize = PAGE_SIZE,
+ initialLoadSize = PAGE_SIZE,
+ enablePlaceholders = false,
+ ),
+ pagingSourceFactory = {
+ NovelSearchPagingSource(
+ query = query,
+ api = api,
+ )
+ },
+ ).flow
+
+ private companion object {
+ const val PAGE_SIZE = 20
+ }
+ }
diff --git a/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchResponseDto.kt b/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchResponseDto.kt
new file mode 100644
index 000000000..baa0f49c4
--- /dev/null
+++ b/data/novel/src/main/java/com/into/websoso/data/novel/NovelSearchResponseDto.kt
@@ -0,0 +1,33 @@
+package com.into.websoso.data.novel
+
+import com.into.websoso.data.novel.model.NovelSearchEntity
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class NovelSearchResponseDto(
+ @SerialName("isLoadable")
+ val isLoadable: Boolean,
+ @SerialName("novels")
+ val novels: List,
+) {
+ @Serializable
+ data class NovelDto(
+ @SerialName("novelId")
+ val novelId: Long,
+ @SerialName("title")
+ val title: String,
+ @SerialName("author")
+ val author: String,
+ @SerialName("novelImage")
+ val imageUrl: String,
+ ) {
+ fun toData(): NovelSearchEntity =
+ NovelSearchEntity(
+ novelId = novelId,
+ title = title,
+ author = author,
+ imageUrl = imageUrl,
+ )
+ }
+}
diff --git a/data/novel/src/main/java/com/into/websoso/data/novel/model/NovelSearchEntity.kt b/data/novel/src/main/java/com/into/websoso/data/novel/model/NovelSearchEntity.kt
new file mode 100644
index 000000000..f5ba6a98e
--- /dev/null
+++ b/data/novel/src/main/java/com/into/websoso/data/novel/model/NovelSearchEntity.kt
@@ -0,0 +1,8 @@
+package com.into.websoso.data.novel.model
+
+data class NovelSearchEntity(
+ val novelId: Long,
+ val title: String,
+ val author: String,
+ val imageUrl: String,
+)
diff --git a/feature/collection/build.gradle.kts b/feature/collection/build.gradle.kts
new file mode 100644
index 000000000..bcdee05eb
--- /dev/null
+++ b/feature/collection/build.gradle.kts
@@ -0,0 +1,17 @@
+import com.into.websoso.setNamespace
+
+plugins {
+ id("websoso.android.feature")
+}
+
+android {
+ setNamespace("feature.collection")
+}
+
+dependencies {
+ implementation(projects.data.library)
+ implementation(projects.data.novel)
+
+ implementation(libs.navigation.compose)
+ implementation(libs.paging.compose)
+}
diff --git a/feature/collection/src/main/AndroidManifest.xml b/feature/collection/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..e10007615
--- /dev/null
+++ b/feature/collection/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionCreateScreen.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionCreateScreen.kt
new file mode 100644
index 000000000..7d17230e3
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionCreateScreen.kt
@@ -0,0 +1,86 @@
+package com.into.websoso.feature.collection
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.core.designsystem.theme.White
+import com.into.websoso.feature.collection.component.CollectionAppBar
+import com.into.websoso.feature.collection.component.CollectionDescriptionInput
+import com.into.websoso.feature.collection.component.CollectionNameInput
+import com.into.websoso.feature.collection.component.CollectionNovelSection
+import com.into.websoso.feature.collection.component.CollectionPrivacySetting
+
+@Composable
+internal fun CollectionCreateScreen(
+ onNavigateBack: () -> Unit,
+ onNavigateToNovelSearch: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var isPrivate by rememberSaveable { mutableStateOf(false) }
+ var collectionName by rememberSaveable { mutableStateOf("") }
+ var collectionDescription by rememberSaveable { mutableStateOf("") }
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .background(White)
+ .statusBarsPadding(),
+ ) {
+ CollectionAppBar(
+ actionLabel = "완료",
+ onNavigateBack = onNavigateBack,
+ )
+ CollectionPrivacySetting(
+ isPrivate = isPrivate,
+ onPrivateChange = { isPrivate = it },
+ )
+ CollectionNameInput(
+ value = collectionName,
+ onValueChange = { collectionName = it },
+ modifier = Modifier.padding(
+ start = 20.dp,
+ top = 20.dp,
+ end = 20.dp,
+ ),
+ )
+ CollectionDescriptionInput(
+ value = collectionDescription,
+ onValueChange = { collectionDescription = it },
+ modifier = Modifier.padding(
+ start = 20.dp,
+ top = 30.dp,
+ end = 20.dp,
+ ),
+ )
+ CollectionNovelSection(
+ onAddNovelClick = onNavigateToNovelSearch,
+ modifier = Modifier.padding(
+ start = 20.dp,
+ top = 30.dp,
+ end = 20.dp,
+ ),
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CollectionCreateScreenPreview() {
+ WebsosoTheme {
+ CollectionCreateScreen(
+ onNavigateBack = {},
+ onNavigateToNovelSearch = {},
+ )
+ }
+}
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionLibraryNovelSelectionScreen.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionLibraryNovelSelectionScreen.kt
new file mode 100644
index 000000000..28e97ce7e
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionLibraryNovelSelectionScreen.kt
@@ -0,0 +1,152 @@
+package com.into.websoso.feature.collection
+
+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.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.GridItemSpan
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.paging.LoadState
+import androidx.paging.PagingData
+import androidx.paging.compose.LazyPagingItems
+import androidx.paging.compose.collectAsLazyPagingItems
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.core.designsystem.theme.White
+import com.into.websoso.feature.collection.component.CollectionAppBar
+import com.into.websoso.feature.collection.component.CollectionLibraryNovelItem
+import com.into.websoso.feature.collection.component.CollectionNetworkError
+import com.into.websoso.feature.collection.model.CollectionLibraryNovelUiModel
+import com.into.websoso.feature.collection.model.CollectionSelectedNovel
+import kotlinx.coroutines.flow.flowOf
+
+@Composable
+internal fun CollectionLibraryNovelSelectionRoute(
+ initialSelectedNovels: List,
+ onAddClick: (List) -> Unit,
+ onNavigateBack: () -> Unit,
+ viewModel: CollectionLibraryNovelSelectionViewModel = hiltViewModel(),
+) {
+ val novels = viewModel.novels.collectAsLazyPagingItems()
+ val selectedNovels by viewModel.selectedNovels.collectAsStateWithLifecycle()
+
+ LaunchedEffect(initialSelectedNovels) {
+ viewModel.setSelectedNovels(initialSelectedNovels)
+ }
+
+ CollectionLibraryNovelSelectionScreen(
+ novels = novels,
+ selectedNovelIds = selectedNovels.mapTo(mutableSetOf()) { it.novelId },
+ onNovelSelectionChange = viewModel::toggleNovelSelection,
+ onAddClick = { onAddClick(selectedNovels) },
+ onNavigateBack = onNavigateBack,
+ )
+}
+
+@Composable
+internal fun CollectionLibraryNovelSelectionScreen(
+ novels: LazyPagingItems,
+ selectedNovelIds: Set,
+ onNovelSelectionChange: (CollectionLibraryNovelUiModel) -> Unit,
+ onAddClick: () -> Unit,
+ onNavigateBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .background(White)
+ .statusBarsPadding(),
+ ) {
+ CollectionAppBar(
+ title = "서재",
+ actionLabel = "추가",
+ onNavigateBack = onNavigateBack,
+ onActionClick = onAddClick,
+ isActionEnabled = selectedNovelIds.isNotEmpty(),
+ )
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ ) {
+ when {
+ novels.itemCount == 0 && novels.loadState.refresh is LoadState.Loading -> {
+ CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
+ }
+
+ (novels.itemCount == 0 && novels.loadState.refresh is LoadState.Error) ||
+ novels.loadState.append is LoadState.Error -> {
+ CollectionNetworkError(
+ onRetryClick = novels::retry,
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+
+ else -> {
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(3),
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(
+ start = 20.dp,
+ top = 11.dp,
+ end = 20.dp,
+ ),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalArrangement = Arrangement.spacedBy(18.dp),
+ ) {
+ items(novels.itemCount) { index ->
+ novels[index]?.let { novel ->
+ CollectionLibraryNovelItem(
+ novel = novel,
+ isSelected = novel.novelId in selectedNovelIds,
+ onSelectionChange = { onNovelSelectionChange(novel) },
+ )
+ }
+ }
+ if (novels.loadState.append is LoadState.Loading) {
+ item(span = { GridItemSpan(maxLineSpan) }) {
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator(modifier = Modifier.padding(16.dp))
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CollectionLibraryNovelSelectionScreenPreview() {
+ WebsosoTheme {
+ CollectionLibraryNovelSelectionScreen(
+ novels = flowOf(PagingData.empty())
+ .collectAsLazyPagingItems(),
+ selectedNovelIds = emptySet(),
+ onNovelSelectionChange = {},
+ onAddClick = {},
+ onNavigateBack = {},
+ )
+ }
+}
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionLibraryNovelSelectionViewModel.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionLibraryNovelSelectionViewModel.kt
new file mode 100644
index 000000000..594436510
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionLibraryNovelSelectionViewModel.kt
@@ -0,0 +1,58 @@
+package com.into.websoso.feature.collection
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.paging.PagingData
+import androidx.paging.cachedIn
+import androidx.paging.map
+import com.into.websoso.data.library.LibraryRepository
+import com.into.websoso.data.library.model.NovelEntity
+import com.into.websoso.feature.collection.mapper.toUiModel
+import com.into.websoso.feature.collection.model.CollectionLibraryNovelUiModel
+import com.into.websoso.feature.collection.model.CollectionSelectedNovel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+import javax.inject.Inject
+
+@HiltViewModel
+internal class CollectionLibraryNovelSelectionViewModel
+ @Inject
+ constructor(
+ libraryRepository: LibraryRepository,
+ ) : ViewModel() {
+ private val _selectedNovels = MutableStateFlow>(emptyList())
+ val selectedNovels: StateFlow> = _selectedNovels.asStateFlow()
+
+ val novels: Flow> =
+ libraryRepository
+ .getUnfilteredLibraryFlow()
+ .map { pagingData -> pagingData.map(NovelEntity::toUiModel) }
+ .cachedIn(viewModelScope)
+
+ fun toggleNovelSelection(novel: CollectionLibraryNovelUiModel) {
+ _selectedNovels.update { selectedNovels ->
+ if (selectedNovels.any { it.novelId == novel.novelId }) {
+ selectedNovels.filterNot { it.novelId == novel.novelId }
+ } else {
+ selectedNovels + novel.toSelectedNovel()
+ }
+ }
+ }
+
+ fun setSelectedNovels(novels: List) {
+ _selectedNovels.value = novels
+ }
+ }
+
+private fun CollectionLibraryNovelUiModel.toSelectedNovel(): CollectionSelectedNovel =
+ CollectionSelectedNovel(
+ novelId = novelId,
+ title = title,
+ author = "",
+ imageUrl = imageUrl,
+ )
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNavHost.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNavHost.kt
new file mode 100644
index 000000000..da8476577
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNavHost.kt
@@ -0,0 +1,75 @@
+package com.into.websoso.feature.collection
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+
+private const val COLLECTION_ROUTE = "collection"
+private const val COLLECTION_CREATE_ROUTE = "collection/create"
+private const val COLLECTION_NOVEL_SEARCH_ROUTE = "collection/create/novel-search"
+private const val COLLECTION_LIBRARY_NOVEL_SELECTION_ROUTE =
+ "collection/create/novel-search/library"
+
+@Composable
+fun CollectionNavHost(
+ onNavigateBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val navController = rememberNavController()
+
+ NavHost(
+ navController = navController,
+ startDestination = COLLECTION_ROUTE,
+ modifier = modifier,
+ ) {
+ composable(route = COLLECTION_ROUTE) {
+ CollectionScreen(
+ onNavigateBack = onNavigateBack,
+ onNavigateToCreate = {
+ navController.navigate(COLLECTION_CREATE_ROUTE)
+ },
+ )
+ }
+ composable(route = COLLECTION_CREATE_ROUTE) {
+ CollectionCreateScreen(
+ onNavigateBack = navController::popBackStack,
+ onNavigateToNovelSearch = {
+ navController.navigate(COLLECTION_NOVEL_SEARCH_ROUTE)
+ },
+ )
+ }
+ composable(route = COLLECTION_NOVEL_SEARCH_ROUTE) { backStackEntry ->
+ val createBackStackEntry = remember(backStackEntry) {
+ navController.getBackStackEntry(COLLECTION_CREATE_ROUTE)
+ }
+ val novelSearchViewModel: CollectionNovelSearchViewModel = hiltViewModel(createBackStackEntry)
+
+ CollectionNovelSearchRoute(
+ viewModel = novelSearchViewModel,
+ onNavigateBack = navController::popBackStack,
+ onNavigateToLibraryNovelSelection = {
+ navController.navigate(COLLECTION_LIBRARY_NOVEL_SELECTION_ROUTE)
+ },
+ )
+ }
+ composable(route = COLLECTION_LIBRARY_NOVEL_SELECTION_ROUTE) { backStackEntry ->
+ val createBackStackEntry = remember(backStackEntry) {
+ navController.getBackStackEntry(COLLECTION_CREATE_ROUTE)
+ }
+ val novelSearchViewModel: CollectionNovelSearchViewModel = hiltViewModel(createBackStackEntry)
+
+ CollectionLibraryNovelSelectionRoute(
+ initialSelectedNovels = novelSearchViewModel.selectedNovels.value,
+ onAddClick = { selectedNovels ->
+ novelSearchViewModel.updateSelectedNovels(selectedNovels)
+ navController.popBackStack()
+ },
+ onNavigateBack = navController::popBackStack,
+ )
+ }
+ }
+}
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNovelSearchScreen.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNovelSearchScreen.kt
new file mode 100644
index 000000000..d92ec4726
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNovelSearchScreen.kt
@@ -0,0 +1,292 @@
+package com.into.websoso.feature.collection
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+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.layout.statusBarsPadding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Text
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.paging.LoadState
+import androidx.paging.PagingData
+import androidx.paging.compose.LazyPagingItems
+import androidx.paging.compose.collectAsLazyPagingItems
+import com.into.websoso.core.designsystem.theme.Gray200
+import com.into.websoso.core.designsystem.theme.Gray50
+import com.into.websoso.core.designsystem.theme.Primary100
+import com.into.websoso.core.designsystem.theme.Primary50
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.core.designsystem.theme.White
+import com.into.websoso.core.resource.R.drawable.ic_explore_not_exist_result
+import com.into.websoso.core.resource.R.string.normal_explore_add_novel_inquire
+import com.into.websoso.core.resource.R.string.normal_explore_not_exist_result
+import com.into.websoso.core.resource.R.string.novel_inquire_link
+import com.into.websoso.data.novel.model.NovelSearchEntity
+import com.into.websoso.feature.collection.component.CollectionAppBar
+import com.into.websoso.feature.collection.component.CollectionNetworkError
+import com.into.websoso.feature.collection.component.CollectionNovelSearchField
+import com.into.websoso.feature.collection.component.CollectionNovelSearchItem
+import com.into.websoso.feature.collection.component.CollectionNovelSelectionInfo
+import com.into.websoso.feature.collection.component.CollectionSelectedNovelItem
+import com.into.websoso.feature.collection.model.CollectionSelectedNovel
+import kotlinx.coroutines.flow.flowOf
+
+@Composable
+internal fun CollectionNovelSearchRoute(
+ onNavigateBack: () -> Unit,
+ onNavigateToLibraryNovelSelection: () -> Unit,
+ viewModel: CollectionNovelSearchViewModel,
+) {
+ val selectedNovels by viewModel.selectedNovels.collectAsStateWithLifecycle()
+ val submittedQuery by viewModel.submittedQuery.collectAsStateWithLifecycle()
+ val searchResults = viewModel.searchResults.collectAsLazyPagingItems()
+
+ CollectionNovelSearchScreen(
+ searchResults = searchResults,
+ submittedQuery = submittedQuery,
+ selectedNovels = selectedNovels,
+ onSearch = { query ->
+ if (query.trim() == submittedQuery && query.isNotBlank()) {
+ searchResults.refresh()
+ } else {
+ viewModel.search(query)
+ }
+ },
+ onAddNovel = viewModel::addNovel,
+ onDeleteNovel = viewModel::removeNovel,
+ onNavigateBack = onNavigateBack,
+ onNavigateToLibraryNovelSelection = onNavigateToLibraryNovelSelection,
+ )
+}
+
+@Composable
+internal fun CollectionNovelSearchScreen(
+ searchResults: LazyPagingItems,
+ submittedQuery: String,
+ selectedNovels: List,
+ onSearch: (String) -> Unit,
+ onAddNovel: (NovelSearchEntity) -> Unit,
+ onDeleteNovel: (Long) -> Unit,
+ onNavigateBack: () -> Unit,
+ onNavigateToLibraryNovelSelection: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var searchQuery by rememberSaveable(stateSaver = TextFieldValue.Saver) {
+ mutableStateOf(TextFieldValue())
+ }
+ val searchFocusRequester = remember { FocusRequester() }
+ val keyboardController = LocalSoftwareKeyboardController.current
+ val selectedNovelIds = selectedNovels.mapTo(mutableSetOf()) { it.novelId }
+
+ LaunchedEffect(Unit) {
+ searchFocusRequester.requestFocus()
+ keyboardController?.show()
+ }
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .background(White)
+ .statusBarsPadding(),
+ ) {
+ CollectionAppBar(
+ title = "작품 리스트",
+ actionLabel = "완료",
+ onNavigateBack = onNavigateBack,
+ onActionClick = onNavigateBack,
+ isActionEnabled = selectedNovels.isNotEmpty(),
+ )
+ CollectionNovelSearchField(
+ value = searchQuery,
+ onValueChange = { searchQuery = it },
+ onClearClick = {
+ searchQuery = TextFieldValue()
+ onSearch("")
+ },
+ onSearchClick = {
+ keyboardController?.hide()
+ onSearch(searchQuery.text)
+ },
+ focusRequester = searchFocusRequester,
+ modifier = Modifier.padding(
+ start = 20.dp,
+ top = 10.dp,
+ end = 20.dp,
+ ),
+ )
+ CollectionNovelSelectionInfo(
+ addedNovelCount = selectedNovels.size,
+ onAddFromLibraryClick = onNavigateToLibraryNovelSelection,
+ modifier = Modifier.padding(top = 16.dp),
+ )
+ PullToRefreshBox(
+ isRefreshing =
+ searchResults.loadState.refresh is LoadState.Loading &&
+ searchResults.itemCount > 0,
+ onRefresh = searchResults::refresh,
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ ) {
+ when {
+ searchResults.loadState.refresh is LoadState.Error -> {
+ CollectionNetworkError(
+ onRetryClick = searchResults::retry,
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+
+ submittedQuery.isNotBlank() &&
+ searchResults.itemCount == 0 &&
+ searchResults.loadState.refresh is LoadState.Loading -> {
+ CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
+ }
+
+ else -> {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(
+ horizontal = 20.dp,
+ vertical = 10.dp,
+ ),
+ ) {
+ items(
+ count = selectedNovels.size,
+ key = { index -> "selected-${selectedNovels[index].novelId}" },
+ ) { index ->
+ val novel = selectedNovels[index]
+ CollectionSelectedNovelItem(
+ novel = novel,
+ onDeleteClick = { onDeleteNovel(novel.novelId) },
+ )
+ }
+
+ if (selectedNovels.isNotEmpty() && submittedQuery.isNotBlank()) {
+ item {
+ HorizontalDivider(
+ modifier = Modifier.padding(vertical = 10.dp),
+ color = Gray50,
+ )
+ }
+ }
+
+ if (submittedQuery.isNotBlank() && searchResults.itemCount == 0) {
+ item { CollectionNovelSearchEmpty() }
+ }
+
+ items(searchResults.itemCount) { index ->
+ searchResults[index]
+ ?.takeUnless { it.novelId in selectedNovelIds }
+ ?.let { novel ->
+ CollectionNovelSearchItem(
+ novel = novel,
+ onAddClick = { onAddNovel(novel) },
+ )
+ }
+ }
+
+ if (searchResults.loadState.append is LoadState.Loading) {
+ item {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CollectionNovelSearchEmpty() {
+ val uriHandler = LocalUriHandler.current
+ val inquireUrl = stringResource(novel_inquire_link)
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 100.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ painter = painterResource(ic_explore_not_exist_result),
+ contentDescription = null,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(normal_explore_not_exist_result),
+ color = Gray200,
+ style = WebsosoTheme.typography.body1,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(modifier = Modifier.height(36.dp))
+ Button(
+ onClick = { uriHandler.openUri(inquireUrl) },
+ shape = RoundedCornerShape(14.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = Primary50),
+ contentPadding = PaddingValues(horizontal = 40.dp, vertical = 20.dp),
+ elevation = null,
+ ) {
+ Text(
+ text = stringResource(normal_explore_add_novel_inquire),
+ color = Primary100,
+ style = WebsosoTheme.typography.title2,
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CollectionNovelSearchScreenPreview() {
+ WebsosoTheme {
+ CollectionNovelSearchScreen(
+ searchResults = flowOf(PagingData.empty()).collectAsLazyPagingItems(),
+ submittedQuery = "",
+ selectedNovels = emptyList(),
+ onSearch = {},
+ onAddNovel = {},
+ onDeleteNovel = {},
+ onNavigateBack = {},
+ onNavigateToLibraryNovelSelection = {},
+ )
+ }
+}
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNovelSearchViewModel.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNovelSearchViewModel.kt
new file mode 100644
index 000000000..e62e164af
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionNovelSearchViewModel.kt
@@ -0,0 +1,75 @@
+package com.into.websoso.feature.collection
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.paging.PagingData
+import androidx.paging.cachedIn
+import com.into.websoso.data.novel.NovelSearchRepository
+import com.into.websoso.data.novel.model.NovelSearchEntity
+import com.into.websoso.feature.collection.model.CollectionSelectedNovel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.update
+import javax.inject.Inject
+
+@HiltViewModel
+internal class CollectionNovelSearchViewModel
+ @Inject
+ constructor(
+ novelSearchRepository: NovelSearchRepository,
+ ) : ViewModel() {
+ private val _submittedQuery = MutableStateFlow("")
+ val submittedQuery: StateFlow = _submittedQuery.asStateFlow()
+
+ private val _selectedNovels = MutableStateFlow>(emptyList())
+ val selectedNovels: StateFlow> = _selectedNovels.asStateFlow()
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val searchResults: Flow> =
+ submittedQuery
+ .flatMapLatest { query ->
+ if (query.isBlank()) {
+ flowOf(PagingData.empty())
+ } else {
+ novelSearchRepository.searchNovels(query)
+ }
+ }.cachedIn(viewModelScope)
+
+ fun search(query: String) {
+ _submittedQuery.value = query.trim()
+ }
+
+ fun addNovel(novel: NovelSearchEntity) {
+ _selectedNovels.update { selectedNovels ->
+ if (selectedNovels.any { it.novelId == novel.novelId }) {
+ selectedNovels
+ } else {
+ selectedNovels + novel.toSelectedNovel()
+ }
+ }
+ }
+
+ fun removeNovel(novelId: Long) {
+ _selectedNovels.update { selectedNovels ->
+ selectedNovels.filterNot { it.novelId == novelId }
+ }
+ }
+
+ fun updateSelectedNovels(novels: List) {
+ _selectedNovels.value = novels
+ }
+ }
+
+private fun NovelSearchEntity.toSelectedNovel(): CollectionSelectedNovel =
+ CollectionSelectedNovel(
+ novelId = novelId,
+ title = title,
+ author = author,
+ imageUrl = imageUrl,
+ )
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionScreen.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionScreen.kt
new file mode 100644
index 000000000..23c2cecd2
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionScreen.kt
@@ -0,0 +1,64 @@
+package com.into.websoso.feature.collection
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.core.designsystem.theme.White
+import com.into.websoso.feature.collection.component.CollectionAppBar
+import com.into.websoso.feature.collection.component.CollectionCreateButton
+import com.into.websoso.feature.collection.component.CollectionTabRow
+import com.into.websoso.feature.collection.model.CollectionTab
+
+@Composable
+fun CollectionScreen(
+ onNavigateBack: () -> Unit,
+ onNavigateToCreate: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var selectedTab by rememberSaveable { mutableStateOf(CollectionTab.MY_COLLECTION) }
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .background(White)
+ .statusBarsPadding(),
+ ) {
+ CollectionAppBar(
+ title = "컬렉션",
+ onNavigateBack = onNavigateBack,
+ )
+ CollectionTabRow(
+ selectedTab = selectedTab,
+ onTabSelected = { selectedTab = it },
+ )
+ CollectionCreateButton(
+ onClick = onNavigateToCreate,
+ modifier = Modifier.padding(
+ horizontal = 20.dp,
+ vertical = 16.dp,
+ ),
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CollectionScreenPreview() {
+ WebsosoTheme {
+ CollectionScreen(
+ onNavigateBack = {},
+ onNavigateToCreate = {},
+ )
+ }
+}
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionAppBar.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionAppBar.kt
new file mode 100644
index 000000000..84dbfbc1f
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionAppBar.kt
@@ -0,0 +1,94 @@
+package com.into.websoso.feature.collection.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.IconButton
+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.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.into.websoso.core.designsystem.theme.Black
+import com.into.websoso.core.designsystem.theme.Gray100
+import com.into.websoso.core.designsystem.theme.Primary100
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.core.designsystem.theme.White
+import com.into.websoso.core.resource.R.drawable.ic_navigate_left
+
+@Composable
+internal fun CollectionAppBar(
+ title: String? = null,
+ actionLabel: String? = null,
+ onNavigateBack: () -> Unit,
+ onActionClick: () -> Unit = {},
+ isActionEnabled: Boolean = false,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(44.dp)
+ .background(White),
+ contentAlignment = Alignment.Center,
+ ) {
+ IconButton(
+ onClick = onNavigateBack,
+ modifier = Modifier
+ .align(Alignment.CenterStart)
+ .padding(start = 6.dp)
+ .size(44.dp),
+ ) {
+ Image(
+ painter = painterResource(id = ic_navigate_left),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ title?.let {
+ Text(
+ text = it,
+ color = Black,
+ style = WebsosoTheme.typography.title2,
+ )
+ }
+ actionLabel?.let {
+ Box(
+ modifier = Modifier
+ .align(Alignment.CenterEnd)
+ .fillMaxHeight()
+ .clickable(
+ enabled = isActionEnabled,
+ onClick = onActionClick,
+ ).padding(horizontal = 20.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = it,
+ color = if (isActionEnabled) Primary100 else Gray100,
+ style = WebsosoTheme.typography.title2,
+ )
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CollectionAppBarPreview() {
+ WebsosoTheme {
+ CollectionAppBar(
+ title = "작품 리스트",
+ actionLabel = "완료",
+ onNavigateBack = {},
+ )
+ }
+}
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionCreateButton.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionCreateButton.kt
new file mode 100644
index 000000000..ace5b1610
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionCreateButton.kt
@@ -0,0 +1,76 @@
+package com.into.websoso.feature.collection.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.into.websoso.core.designsystem.theme.Gray20
+import com.into.websoso.core.designsystem.theme.Primary100
+import com.into.websoso.core.designsystem.theme.Primary30
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.core.resource.R.drawable.ic_collection_create
+
+@Composable
+internal fun CollectionCreateButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val shape = RoundedCornerShape(12.dp)
+
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(46.dp)
+ .clip(shape)
+ .background(
+ color = Gray20,
+ shape = shape,
+ ).border(
+ width = 1.dp,
+ color = Primary30,
+ shape = shape,
+ ).clickable(
+ onClick = onClick,
+ role = Role.Button,
+ ),
+ horizontalArrangement = Arrangement.spacedBy(
+ space = 10.dp,
+ alignment = Alignment.CenterHorizontally,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = "컬렉션 만들기",
+ color = Primary100,
+ style = WebsosoTheme.typography.title3,
+ )
+ Image(
+ painter = painterResource(id = ic_collection_create),
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CollectionCreateButtonPreview() {
+ WebsosoTheme {
+ CollectionCreateButton(onClick = {})
+ }
+}
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionDescriptionInput.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionDescriptionInput.kt
new file mode 100644
index 000000000..efffb92af
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionDescriptionInput.kt
@@ -0,0 +1,98 @@
+package com.into.websoso.feature.collection.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.into.websoso.core.designsystem.theme.Black
+import com.into.websoso.core.designsystem.theme.Gray100
+import com.into.websoso.core.designsystem.theme.Gray200
+import com.into.websoso.core.designsystem.theme.Gray50
+import com.into.websoso.core.designsystem.theme.Primary100
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+
+private const val COLLECTION_DESCRIPTION_MAX_LENGTH = 60
+
+@Composable
+internal fun CollectionDescriptionInput(
+ value: String,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Text(
+ text = "컬렉션 설명",
+ color = Black,
+ style = WebsosoTheme.typography.title2,
+ )
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(112.dp)
+ .background(
+ color = Gray50,
+ shape = RoundedCornerShape(14.dp),
+ ).padding(
+ horizontal = 16.dp,
+ vertical = 18.dp,
+ ),
+ ) {
+ BasicTextField(
+ value = value,
+ onValueChange = { changedValue ->
+ onValueChange(changedValue.take(COLLECTION_DESCRIPTION_MAX_LENGTH))
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ textStyle = WebsosoTheme.typography.body2.copy(color = Black),
+ cursorBrush = SolidColor(Primary100),
+ decorationBox = { innerTextField ->
+ Box {
+ if (value.isEmpty()) {
+ Text(
+ text = "컬렉션에 관련한 설명을 간단하게 작성해주세요",
+ color = Gray100,
+ style = WebsosoTheme.typography.body2,
+ )
+ }
+ innerTextField()
+ }
+ },
+ )
+ Text(
+ text = "(${value.length}/$COLLECTION_DESCRIPTION_MAX_LENGTH)",
+ color = Gray200,
+ style = WebsosoTheme.typography.body2,
+ modifier = Modifier.align(Alignment.End),
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CollectionDescriptionInputPreview() {
+ WebsosoTheme {
+ CollectionDescriptionInput(
+ value = "",
+ onValueChange = {},
+ modifier = Modifier.padding(20.dp),
+ )
+ }
+}
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionEntry.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionEntry.kt
new file mode 100644
index 000000000..f12c74ebb
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionEntry.kt
@@ -0,0 +1,72 @@
+package com.into.websoso.feature.collection.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.into.websoso.core.designsystem.theme.Gray300
+import com.into.websoso.core.designsystem.theme.Primary100
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.core.designsystem.theme.White
+import com.into.websoso.core.resource.R
+
+@Composable
+fun CollectionEntry(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ collectionCount: Int = 0,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(White)
+ .clickable(onClick = onClick)
+ .padding(
+ horizontal = 20.dp,
+ vertical = 20.dp,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = "컬렉션 ",
+ color = Gray300,
+ style = WebsosoTheme.typography.title2,
+ )
+ Text(
+ text = collectionCount.toString(),
+ color = Primary100,
+ style = WebsosoTheme.typography.title2,
+ )
+ Text(
+ text = "개",
+ color = Gray300,
+ style = WebsosoTheme.typography.title2,
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ Image(
+ imageVector = ImageVector.vectorResource(R.drawable.btn_setting_right),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CollectionEntryPreview() {
+ WebsosoTheme {
+ CollectionEntry(onClick = {})
+ }
+}
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionLibraryNovelItem.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionLibraryNovelItem.kt
new file mode 100644
index 000000000..046466b19
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionLibraryNovelItem.kt
@@ -0,0 +1,199 @@
+package com.into.websoso.feature.collection.component
+
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.into.websoso.core.designsystem.component.NetworkImage
+import com.into.websoso.core.designsystem.theme.Black
+import com.into.websoso.core.designsystem.theme.Gray200
+import com.into.websoso.core.designsystem.theme.Primary100
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.core.designsystem.theme.White
+import com.into.websoso.core.resource.R.drawable.ic_library_half_star
+import com.into.websoso.core.resource.R.drawable.ic_library_interesting
+import com.into.websoso.core.resource.R.drawable.ic_library_null_star
+import com.into.websoso.core.resource.R.drawable.ic_novel_detail_check
+import com.into.websoso.core.resource.R.drawable.ic_novel_unselected
+import com.into.websoso.core.resource.R.drawable.ic_storage_star
+import com.into.websoso.feature.collection.model.CollectionLibraryNovelUiModel
+import com.into.websoso.feature.collection.model.CollectionLibraryRatingStar
+import com.into.websoso.feature.collection.model.CollectionLibraryReadStatus
+
+@Composable
+internal fun CollectionLibraryNovelItem(
+ novel: CollectionLibraryNovelUiModel,
+ isSelected: Boolean,
+ onSelectionChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier.toggleable(
+ value = isSelected,
+ role = Role.Checkbox,
+ onValueChange = onSelectionChange,
+ ),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(102.67f / 160f)
+ .clip(RoundedCornerShape(8.dp)),
+ ) {
+ NetworkImage(
+ imageUrl = novel.imageUrl,
+ contentDescription = novel.title,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+ novel.readStatus?.let { readStatus ->
+ Box(
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(
+ start = 6.dp,
+ bottom = 7.dp,
+ ).size(
+ width = 48.dp,
+ height = 18.dp,
+ ).background(
+ color = readStatus.backgroundColor,
+ shape = RoundedCornerShape(4.dp),
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = readStatus.label,
+ color = White,
+ style = WebsosoTheme.typography.label2,
+ )
+ }
+ }
+ if (novel.isInterested) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .size(32.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Image(
+ imageVector = ImageVector.vectorResource(ic_library_interesting),
+ contentDescription = null,
+ modifier = Modifier.size(13.dp),
+ )
+ }
+ }
+ Image(
+ imageVector = ImageVector.vectorResource(
+ if (isSelected) ic_novel_detail_check else ic_novel_unselected,
+ ),
+ contentDescription = null,
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(
+ top = 8.dp,
+ end = 8.dp,
+ ).size(24.dp),
+ )
+ }
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(
+ text = novel.title,
+ color = Black,
+ style = WebsosoTheme.typography.body4,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (novel.ratingStars.isNotEmpty()) {
+ Row(
+ modifier = Modifier.padding(bottom = 4.dp),
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ novel.ratingStars.forEach { star ->
+ Image(
+ imageVector = ImageVector.vectorResource(star.iconRes),
+ contentDescription = null,
+ modifier = Modifier.size(9.dp),
+ )
+ }
+ }
+ }
+ novel.dateText?.let {
+ Text(
+ text = it,
+ color = Gray200,
+ style = WebsosoTheme.typography.label2,
+ )
+ }
+ }
+ }
+}
+
+private val CollectionLibraryReadStatus.label: String
+ get() = when (this) {
+ CollectionLibraryReadStatus.WATCHING -> "보는 중"
+ CollectionLibraryReadStatus.WATCHED -> "봤어요"
+ CollectionLibraryReadStatus.QUIT -> "하차"
+ }
+
+private val CollectionLibraryReadStatus.backgroundColor: Color
+ get() = when (this) {
+ CollectionLibraryReadStatus.WATCHING -> Primary100
+ CollectionLibraryReadStatus.WATCHED -> Black
+ CollectionLibraryReadStatus.QUIT -> Gray200
+ }
+
+@get:DrawableRes
+private val CollectionLibraryRatingStar.iconRes: Int
+ get() = when (this) {
+ CollectionLibraryRatingStar.FULL -> ic_storage_star
+ CollectionLibraryRatingStar.HALF -> ic_library_half_star
+ CollectionLibraryRatingStar.EMPTY -> ic_library_null_star
+ }
+
+@Preview(showBackground = true)
+@Composable
+private fun CollectionLibraryNovelItemPreview() {
+ WebsosoTheme {
+ CollectionLibraryNovelItem(
+ novel = CollectionLibraryNovelUiModel(
+ novelId = 1L,
+ title = "당신의 이해를 돕기 위하여",
+ imageUrl = "",
+ readStatus = CollectionLibraryReadStatus.WATCHING,
+ isInterested = true,
+ ratingStars = List(5) { CollectionLibraryRatingStar.FULL },
+ dateText = "24.01.03 ~ 25.03.08",
+ ),
+ isSelected = false,
+ onSelectionChange = {},
+ modifier = Modifier
+ .padding(20.dp)
+ .size(width = 103.dp, height = 240.dp),
+ )
+ }
+}
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionNameInput.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionNameInput.kt
new file mode 100644
index 000000000..74856222f
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionNameInput.kt
@@ -0,0 +1,107 @@
+package com.into.websoso.feature.collection.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.into.websoso.core.designsystem.theme.Black
+import com.into.websoso.core.designsystem.theme.Gray100
+import com.into.websoso.core.designsystem.theme.Gray200
+import com.into.websoso.core.designsystem.theme.Gray50
+import com.into.websoso.core.designsystem.theme.Primary100
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+
+private const val COLLECTION_NAME_MAX_LENGTH = 20
+
+@Composable
+internal fun CollectionNameInput(
+ value: String,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Text(
+ text = buildAnnotatedString {
+ append("컬렉션 이름 ")
+ withStyle(style = SpanStyle(color = Primary100)) {
+ append("*")
+ }
+ },
+ color = Black,
+ style = WebsosoTheme.typography.title2,
+ )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(44.dp)
+ .background(
+ color = Gray50,
+ shape = RoundedCornerShape(12.dp),
+ ).padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ BasicTextField(
+ value = value,
+ onValueChange = { changedValue ->
+ onValueChange(changedValue.take(COLLECTION_NAME_MAX_LENGTH))
+ },
+ modifier = Modifier.weight(1f),
+ singleLine = true,
+ textStyle = WebsosoTheme.typography.body2.copy(color = Black),
+ cursorBrush = SolidColor(Primary100),
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
+ decorationBox = { innerTextField ->
+ Box {
+ if (value.isEmpty()) {
+ Text(
+ text = "컬렉션 이름을 입력해주세요",
+ color = Gray100,
+ style = WebsosoTheme.typography.body2,
+ )
+ }
+ innerTextField()
+ }
+ },
+ )
+ Text(
+ text = "(${value.length}/$COLLECTION_NAME_MAX_LENGTH)",
+ color = Gray200,
+ style = WebsosoTheme.typography.body2,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CollectionNameInputPreview() {
+ WebsosoTheme {
+ CollectionNameInput(
+ value = "",
+ onValueChange = {},
+ modifier = Modifier.padding(20.dp),
+ )
+ }
+}
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionNetworkError.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionNetworkError.kt
new file mode 100644
index 000000000..aae52038f
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionNetworkError.kt
@@ -0,0 +1,73 @@
+package com.into.websoso.feature.collection.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+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.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.into.websoso.core.designsystem.theme.Black
+import com.into.websoso.core.designsystem.theme.Gray300
+import com.into.websoso.core.designsystem.theme.Primary100
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.core.designsystem.theme.White
+import com.into.websoso.core.resource.R.drawable.img_load_fail
+import com.into.websoso.core.resource.R.string.load_fail_description
+import com.into.websoso.core.resource.R.string.load_fail_reload
+import com.into.websoso.core.resource.R.string.load_fail_title
+
+@Composable
+internal fun CollectionNetworkError(
+ onRetryClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ painter = painterResource(img_load_fail),
+ contentDescription = null,
+ modifier = Modifier.size(width = 166.dp, height = 160.dp),
+ )
+ Spacer(modifier = Modifier.height(40.dp))
+ Text(
+ text = stringResource(load_fail_title),
+ color = Black,
+ style = WebsosoTheme.typography.title1,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+ Text(
+ text = stringResource(load_fail_description),
+ color = Gray300,
+ style = WebsosoTheme.typography.body2,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(modifier = Modifier.height(40.dp))
+ Button(
+ onClick = onRetryClick,
+ shape = RoundedCornerShape(8.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = Primary100),
+ contentPadding = PaddingValues(horizontal = 38.dp, vertical = 14.dp),
+ elevation = null,
+ ) {
+ Text(
+ text = stringResource(load_fail_reload),
+ color = White,
+ style = WebsosoTheme.typography.label1,
+ )
+ }
+ }
+}
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionNovelSearchField.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionNovelSearchField.kt
new file mode 100644
index 000000000..ba0948339
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionNovelSearchField.kt
@@ -0,0 +1,129 @@
+package com.into.websoso.feature.collection.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.into.websoso.core.designsystem.theme.Black
+import com.into.websoso.core.designsystem.theme.Gray100
+import com.into.websoso.core.designsystem.theme.Gray70New
+import com.into.websoso.core.designsystem.theme.Primary100
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.core.resource.R.drawable.ic_common_search
+import com.into.websoso.core.resource.R.drawable.ic_common_search_clear
+
+@Composable
+internal fun CollectionNovelSearchField(
+ value: TextFieldValue,
+ onValueChange: (TextFieldValue) -> Unit,
+ onClearClick: () -> Unit,
+ onSearchClick: () -> Unit,
+ focusRequester: FocusRequester,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(42.dp)
+ .border(
+ width = 1.dp,
+ color = Gray70New,
+ shape = RoundedCornerShape(14.dp),
+ ).padding(
+ start = 16.dp,
+ end = 10.dp,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ BasicTextField(
+ value = value,
+ onValueChange = onValueChange,
+ modifier = Modifier
+ .weight(1f)
+ .focusRequester(focusRequester),
+ singleLine = true,
+ textStyle = WebsosoTheme.typography.body4.copy(color = Black),
+ cursorBrush = SolidColor(Primary100),
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+ keyboardActions = KeyboardActions(onSearch = { onSearchClick() }),
+ decorationBox = { innerTextField ->
+ Box {
+ if (value.text.isEmpty()) {
+ Text(
+ text = "작품 제목, 작가를 검색하세요",
+ color = Gray100,
+ style = WebsosoTheme.typography.body4,
+ )
+ }
+ innerTextField()
+ }
+ },
+ )
+ if (value.text.isNotEmpty()) {
+ IconButton(
+ onClick = onClearClick,
+ modifier = Modifier.size(36.dp),
+ ) {
+ Image(
+ painter = painterResource(id = ic_common_search_clear),
+ contentDescription = "검색어 지우기",
+ modifier = Modifier.size(36.dp),
+ )
+ }
+ }
+ Box(
+ modifier = Modifier
+ .size(36.dp)
+ .clickable(
+ onClick = onSearchClick,
+ role = Role.Button,
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Image(
+ painter = painterResource(id = ic_common_search),
+ contentDescription = "작품 검색",
+ modifier = Modifier.size(width = 25.dp, height = 26.dp),
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CollectionNovelSearchFieldPreview() {
+ WebsosoTheme {
+ CollectionNovelSearchField(
+ value = TextFieldValue(),
+ onValueChange = {},
+ onClearClick = {},
+ onSearchClick = {},
+ focusRequester = remember { FocusRequester() },
+ modifier = Modifier.padding(20.dp),
+ )
+ }
+}
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionNovelSearchItem.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionNovelSearchItem.kt
new file mode 100644
index 000000000..a889d5b0d
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionNovelSearchItem.kt
@@ -0,0 +1,141 @@
+package com.into.websoso.feature.collection.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.into.websoso.core.designsystem.component.NetworkImage
+import com.into.websoso.core.designsystem.theme.Black
+import com.into.websoso.core.designsystem.theme.Gray200
+import com.into.websoso.core.designsystem.theme.LightPink
+import com.into.websoso.core.designsystem.theme.Primary100
+import com.into.websoso.core.designsystem.theme.Secondary100
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.core.designsystem.theme.White
+import com.into.websoso.core.resource.R.drawable.ic_collection_novel_add
+import com.into.websoso.core.resource.R.drawable.ic_collection_novel_delete
+import com.into.websoso.data.novel.model.NovelSearchEntity
+import com.into.websoso.feature.collection.model.CollectionSelectedNovel
+
+@Composable
+internal fun CollectionNovelSearchItem(
+ title: String,
+ author: String,
+ imageUrl: String,
+ actionLabel: String,
+ onActionClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ NetworkImage(
+ imageUrl = imageUrl,
+ contentDescription = title,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .size(width = 73.dp, height = 98.dp)
+ .clip(RoundedCornerShape(8.dp)),
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = title,
+ color = Black,
+ style = WebsosoTheme.typography.title3,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (author.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = author,
+ color = Gray200,
+ style = WebsosoTheme.typography.body5,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ Spacer(modifier = Modifier.width(16.dp))
+ val isAdd = actionLabel == "추가"
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(22.dp))
+ .background(if (isAdd) Primary100 else LightPink)
+ .clickable(onClick = onActionClick)
+ .padding(horizontal = 10.dp, vertical = 7.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painter = painterResource(
+ id = if (isAdd) ic_collection_novel_add else ic_collection_novel_delete,
+ ),
+ tint = if (isAdd) White else Secondary100,
+ contentDescription = null,
+ modifier = Modifier.size(12.dp),
+ )
+ Text(
+ text = actionLabel,
+ color = if (isAdd) White else Secondary100,
+ style = WebsosoTheme.typography.body5,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+internal fun CollectionNovelSearchItem(
+ novel: NovelSearchEntity,
+ onAddClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ CollectionNovelSearchItem(
+ title = novel.title,
+ author = novel.author,
+ imageUrl = novel.imageUrl,
+ actionLabel = "추가",
+ onActionClick = onAddClick,
+ modifier = modifier,
+ )
+}
+
+@Composable
+internal fun CollectionSelectedNovelItem(
+ novel: CollectionSelectedNovel,
+ onDeleteClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ CollectionNovelSearchItem(
+ title = novel.title,
+ author = novel.author,
+ imageUrl = novel.imageUrl,
+ actionLabel = "삭제",
+ onActionClick = onDeleteClick,
+ modifier = modifier,
+ )
+}
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionNovelSection.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionNovelSection.kt
new file mode 100644
index 000000000..f5dfffa7f
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionNovelSection.kt
@@ -0,0 +1,98 @@
+package com.into.websoso.feature.collection.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.into.websoso.core.designsystem.theme.Black
+import com.into.websoso.core.designsystem.theme.Gray200
+import com.into.websoso.core.designsystem.theme.Gray50
+import com.into.websoso.core.designsystem.theme.Primary100
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.core.resource.R.drawable.ic_plus_novel
+
+@Composable
+internal fun CollectionNovelSection(
+ onAddNovelClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val addCardShape = RoundedCornerShape(8.dp)
+
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(9.dp),
+ verticalAlignment = Alignment.Bottom,
+ ) {
+ Text(
+ text = buildAnnotatedString {
+ append("작품 리스트 ")
+ withStyle(style = SpanStyle(color = Primary100)) {
+ append("*")
+ }
+ },
+ color = Black,
+ style = WebsosoTheme.typography.title2,
+ )
+ Text(
+ text = "(0/100)",
+ color = Gray200,
+ style = WebsosoTheme.typography.body3,
+ )
+ }
+ Column(
+ modifier = Modifier
+ .size(width = 103.dp, height = 160.dp)
+ .clip(addCardShape)
+ .background(
+ color = Gray50,
+ shape = addCardShape,
+ ).clickable(
+ onClick = onAddNovelClick,
+ role = Role.Button,
+ ),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ text = "작품 추가",
+ color = Gray200,
+ style = WebsosoTheme.typography.title4,
+ modifier = Modifier.padding(bottom = 4.dp),
+ )
+ Icon(
+ painter = painterResource(id = ic_plus_novel),
+ contentDescription = null,
+ tint = Gray200,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CollectionNovelSectionPreview() {
+ WebsosoTheme {
+ CollectionNovelSection(onAddNovelClick = {})
+ }
+}
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionNovelSelectionInfo.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionNovelSelectionInfo.kt
new file mode 100644
index 000000000..f2ff57cb9
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionNovelSelectionInfo.kt
@@ -0,0 +1,68 @@
+package com.into.websoso.feature.collection.component
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.into.websoso.core.designsystem.theme.Gray200
+import com.into.websoso.core.designsystem.theme.Primary100
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+
+@Composable
+internal fun CollectionNovelSelectionInfo(
+ addedNovelCount: Int,
+ onAddFromLibraryClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(
+ text = "추가한 작품",
+ color = Gray200,
+ style = WebsosoTheme.typography.body4,
+ )
+ Text(
+ text = "${addedNovelCount}개",
+ color = Primary100,
+ style = WebsosoTheme.typography.body4,
+ )
+ }
+ Text(
+ text = "서재에서 추가",
+ color = Gray200,
+ style = WebsosoTheme.typography.body4.copy(
+ textDecoration = TextDecoration.Underline,
+ ),
+ modifier = Modifier.clickable(
+ onClick = onAddFromLibraryClick,
+ role = Role.Button,
+ ),
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CollectionNovelSelectionInfoPreview() {
+ WebsosoTheme {
+ CollectionNovelSelectionInfo(
+ addedNovelCount = 0,
+ onAddFromLibraryClick = {},
+ )
+ }
+}
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionPrivacySetting.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionPrivacySetting.kt
new file mode 100644
index 000000000..ec2921be2
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionPrivacySetting.kt
@@ -0,0 +1,114 @@
+package com.into.websoso.feature.collection.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.into.websoso.core.designsystem.theme.Gray100
+import com.into.websoso.core.designsystem.theme.Gray200
+import com.into.websoso.core.designsystem.theme.Gray300
+import com.into.websoso.core.designsystem.theme.Gray50
+import com.into.websoso.core.designsystem.theme.Primary100
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.core.designsystem.theme.White
+import com.into.websoso.core.resource.R.drawable.ic_lock
+
+@Composable
+internal fun CollectionPrivacySetting(
+ isPrivate: Boolean,
+ onPrivateChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(Gray300)
+ .padding(horizontal = 20.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painter = painterResource(id = ic_lock),
+ contentDescription = null,
+ tint = Gray200,
+ modifier = Modifier.size(18.dp),
+ )
+ Text(
+ text = "나만 보는 컬렉션",
+ color = Gray50,
+ style = WebsosoTheme.typography.title3,
+ modifier = Modifier
+ .padding(start = 4.dp)
+ .weight(1f),
+ )
+ CollectionPrivacySwitch(
+ checked = isPrivate,
+ onCheckedChange = onPrivateChange,
+ )
+ }
+}
+
+@Composable
+private fun CollectionPrivacySwitch(
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+) {
+ Box(
+ modifier = Modifier
+ .size(42.dp)
+ .semantics {
+ contentDescription = "나만 보는 컬렉션"
+ }.toggleable(
+ value = checked,
+ role = Role.Switch,
+ onValueChange = onCheckedChange,
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(width = 40.dp, height = 22.dp)
+ .background(
+ color = if (checked) Primary100 else Gray100,
+ shape = RoundedCornerShape(11.dp),
+ ).padding(2.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .size(18.dp)
+ .offset(x = if (checked) 18.dp else 0.dp)
+ .background(
+ color = White,
+ shape = CircleShape,
+ ),
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CollectionPrivacySettingPreview() {
+ WebsosoTheme {
+ CollectionPrivacySetting(
+ isPrivate = false,
+ onPrivateChange = {},
+ )
+ }
+}
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionTabRow.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionTabRow.kt
new file mode 100644
index 000000000..ee08a0a6a
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionTabRow.kt
@@ -0,0 +1,111 @@
+package com.into.websoso.feature.collection.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.into.websoso.core.designsystem.theme.Black
+import com.into.websoso.core.designsystem.theme.Gray100
+import com.into.websoso.core.designsystem.theme.Gray70New
+import com.into.websoso.core.designsystem.theme.WebsosoTheme
+import com.into.websoso.core.designsystem.theme.White
+import com.into.websoso.feature.collection.model.CollectionTab
+
+@Composable
+internal fun CollectionTabRow(
+ selectedTab: CollectionTab,
+ onTabSelected: (CollectionTab) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(47.dp)
+ .background(White),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(46.dp),
+ ) {
+ CollectionTab.entries.forEach { tab ->
+ CollectionTabItem(
+ title = tab.title,
+ isSelected = tab == selectedTab,
+ onClick = { onTabSelected(tab) },
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ Box(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .height(1.dp)
+ .background(Gray70New),
+ )
+ }
+}
+
+@Composable
+private fun CollectionTabItem(
+ title: String,
+ isSelected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .height(46.dp)
+ .selectable(
+ selected = isSelected,
+ onClick = onClick,
+ role = Role.Tab,
+ ).padding(top = 12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = title,
+ color = if (isSelected) Black else Gray100,
+ style = WebsosoTheme.typography.title2,
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ if (isSelected) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(2.dp)
+ .background(Black),
+ )
+ }
+ }
+}
+
+private val CollectionTab.title: String
+ get() = when (this) {
+ CollectionTab.MY_COLLECTION -> "내 컬렉션"
+ CollectionTab.LIKED_COLLECTION -> "좋아요한 컬렉션"
+ }
+
+@Preview(showBackground = true)
+@Composable
+private fun CollectionTabRowPreview() {
+ WebsosoTheme {
+ CollectionTabRow(
+ selectedTab = CollectionTab.MY_COLLECTION,
+ onTabSelected = {},
+ )
+ }
+}
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/mapper/CollectionLibraryNovelMapper.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/mapper/CollectionLibraryNovelMapper.kt
new file mode 100644
index 000000000..c5e9b532e
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/mapper/CollectionLibraryNovelMapper.kt
@@ -0,0 +1,32 @@
+package com.into.websoso.feature.collection.mapper
+
+import com.into.websoso.core.common.extensions.formatDateRange
+import com.into.websoso.data.library.model.NovelEntity
+import com.into.websoso.feature.collection.model.CollectionLibraryNovelUiModel
+import com.into.websoso.feature.collection.model.CollectionLibraryRatingStar
+import com.into.websoso.feature.collection.model.CollectionLibraryReadStatus
+
+internal fun NovelEntity.toUiModel(): CollectionLibraryNovelUiModel =
+ CollectionLibraryNovelUiModel(
+ novelId = novelId,
+ title = title,
+ imageUrl = novelImage,
+ readStatus = CollectionLibraryReadStatus.entries.find { it.name == readStatus },
+ isInterested = isInterest,
+ ratingStars = userNovelRating.toRatingStars(),
+ dateText = formatDateRange(startDate, endDate),
+ )
+
+private fun Float.toRatingStars(): List {
+ if (this !in 0.5f..5f) return emptyList()
+
+ val fullStarCount = toInt()
+ val hasHalfStar = this - fullStarCount >= 0.5f
+ val emptyStarCount = 5 - fullStarCount - if (hasHalfStar) 1 else 0
+
+ return buildList {
+ repeat(fullStarCount) { add(CollectionLibraryRatingStar.FULL) }
+ if (hasHalfStar) add(CollectionLibraryRatingStar.HALF)
+ repeat(emptyStarCount) { add(CollectionLibraryRatingStar.EMPTY) }
+ }
+}
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/model/CollectionLibraryNovelUiModel.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/model/CollectionLibraryNovelUiModel.kt
new file mode 100644
index 000000000..54dc4c5bd
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/model/CollectionLibraryNovelUiModel.kt
@@ -0,0 +1,23 @@
+package com.into.websoso.feature.collection.model
+
+internal data class CollectionLibraryNovelUiModel(
+ val novelId: Long,
+ val title: String,
+ val imageUrl: String,
+ val readStatus: CollectionLibraryReadStatus?,
+ val isInterested: Boolean,
+ val ratingStars: List,
+ val dateText: String?,
+)
+
+internal enum class CollectionLibraryReadStatus {
+ WATCHING,
+ WATCHED,
+ QUIT,
+}
+
+internal enum class CollectionLibraryRatingStar {
+ FULL,
+ HALF,
+ EMPTY,
+}
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/model/CollectionSelectedNovel.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/model/CollectionSelectedNovel.kt
new file mode 100644
index 000000000..766d6e579
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/model/CollectionSelectedNovel.kt
@@ -0,0 +1,8 @@
+package com.into.websoso.feature.collection.model
+
+internal data class CollectionSelectedNovel(
+ val novelId: Long,
+ val title: String,
+ val author: String,
+ val imageUrl: String,
+)
diff --git a/feature/collection/src/main/java/com/into/websoso/feature/collection/model/CollectionTab.kt b/feature/collection/src/main/java/com/into/websoso/feature/collection/model/CollectionTab.kt
new file mode 100644
index 000000000..0e2ff87e0
--- /dev/null
+++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/model/CollectionTab.kt
@@ -0,0 +1,6 @@
+package com.into.websoso.feature.collection.model
+
+internal enum class CollectionTab {
+ MY_COLLECTION,
+ LIKED_COLLECTION,
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e2bcebb8b..f1901b57f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -32,6 +32,7 @@ lifecycle-extensions = "2.2.0"
datastore-preferences = "1.2.0"
security-crypto = "1.1.0"
paging = "3.3.6"
+navigation = "2.9.8"
# Testing Libraries
junit = "4.13.2"
@@ -167,6 +168,7 @@ compose-material3 = { module = "androidx.compose.material3:material3" }
compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-hilt-navigation = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-navigation-compose" }
+navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose" }
androidx-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index e23a00fed..e814f4de1 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -33,6 +33,7 @@ include(
include(
":data:account",
":data:library",
+ ":data:novel",
":data:feed",
)
@@ -44,6 +45,7 @@ include(
":feature:signin",
":feature:library",
":feature:feed",
+ ":feature:collection",
)
include(":domain:feed")
include(":data:user")