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")