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/feature/collection/build.gradle.kts b/feature/collection/build.gradle.kts index 9018af585..163a265f6 100644 --- a/feature/collection/build.gradle.kts +++ b/feature/collection/build.gradle.kts @@ -9,5 +9,8 @@ android { } dependencies { + implementation(projects.data.library) + implementation(libs.navigation.compose) + implementation(libs.paging.compose) } 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..e5f0bd184 --- /dev/null +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionLibraryNovelSelectionScreen.kt @@ -0,0 +1,202 @@ +package com.into.websoso.feature.collection + +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.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.size +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.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.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.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 +import com.into.websoso.feature.collection.component.CollectionAppBar +import com.into.websoso.feature.collection.component.CollectionLibraryNovelItem +import com.into.websoso.feature.collection.model.CollectionLibraryNovelUiModel +import kotlinx.coroutines.flow.flowOf + +@Composable +internal fun CollectionLibraryNovelSelectionRoute( + initialSelectedNovelIds: Set, + onAddClick: (Set) -> Unit, + onNavigateBack: () -> Unit, + viewModel: CollectionLibraryNovelSelectionViewModel = hiltViewModel(), +) { + val novels = viewModel.novels.collectAsLazyPagingItems() + val selectedNovelIds by viewModel.selectedNovelIds.collectAsStateWithLifecycle() + + LaunchedEffect(initialSelectedNovelIds) { + viewModel.setSelectedNovelIds(initialSelectedNovelIds) + } + + CollectionLibraryNovelSelectionScreen( + novels = novels, + selectedNovelIds = selectedNovelIds, + onNovelSelectionChange = viewModel::toggleNovelSelection, + onAddClick = { onAddClick(selectedNovelIds) }, + onNavigateBack = onNavigateBack, + ) +} + +@Composable +internal fun CollectionLibraryNovelSelectionScreen( + novels: LazyPagingItems, + selectedNovelIds: Set, + onNovelSelectionChange: (Long) -> 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 -> { + Column( + modifier = Modifier.align(Alignment.Center), + 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 = novels::retry, + 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, + ) + } + } + } + + 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.novelId) }, + ) + } + } + 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..b3644b9cb --- /dev/null +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/CollectionLibraryNovelSelectionViewModel.kt @@ -0,0 +1,49 @@ +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 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 _selectedNovelIds = MutableStateFlow>(emptySet()) + val selectedNovelIds: StateFlow> = _selectedNovelIds.asStateFlow() + + val novels: Flow> = + libraryRepository + .getUnfilteredLibraryFlow() + .map { pagingData -> pagingData.map(NovelEntity::toUiModel) } + .cachedIn(viewModelScope) + + fun toggleNovelSelection(novelId: Long) { + _selectedNovelIds.update { selectedNovelIds -> + if (novelId in selectedNovelIds) { + selectedNovelIds - novelId + } else { + selectedNovelIds + novelId + } + } + } + + fun setSelectedNovelIds(novelIds: Set) { + _selectedNovelIds.value = novelIds + } + } 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 index cdcae84e5..f6dc8c7ae 100644 --- 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 @@ -1,7 +1,9 @@ package com.into.websoso.feature.collection import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -9,6 +11,9 @@ 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" +private const val SELECTED_NOVEL_IDS_KEY = "selectedNovelIds" @Composable fun CollectionNavHost( @@ -38,8 +43,34 @@ fun CollectionNavHost( }, ) } - composable(route = COLLECTION_NOVEL_SEARCH_ROUTE) { + composable(route = COLLECTION_NOVEL_SEARCH_ROUTE) { backStackEntry -> + val selectedNovelIds by backStackEntry.savedStateHandle + .getStateFlow(SELECTED_NOVEL_IDS_KEY, longArrayOf()) + .collectAsStateWithLifecycle() + CollectionNovelSearchScreen( + addedNovelCount = selectedNovelIds.size, + onNavigateBack = navController::popBackStack, + onNavigateToLibraryNovelSelection = { + navController.navigate(COLLECTION_LIBRARY_NOVEL_SELECTION_ROUTE) + }, + ) + } + composable(route = COLLECTION_LIBRARY_NOVEL_SELECTION_ROUTE) { + val initialSelectedNovelIds = navController.previousBackStackEntry + ?.savedStateHandle + ?.get(SELECTED_NOVEL_IDS_KEY) + ?.toSet() + .orEmpty() + + CollectionLibraryNovelSelectionRoute( + initialSelectedNovelIds = initialSelectedNovelIds, + onAddClick = { selectedNovelIds -> + navController.previousBackStackEntry + ?.savedStateHandle + ?.set(SELECTED_NOVEL_IDS_KEY, selectedNovelIds.toLongArray()) + 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 index 183f65357..52262404f 100644 --- 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 @@ -22,10 +22,13 @@ 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.CollectionNovelSearchField +import com.into.websoso.feature.collection.component.CollectionNovelSelectionInfo @Composable internal fun CollectionNovelSearchScreen( + addedNovelCount: Int, onNavigateBack: () -> Unit, + onNavigateToLibraryNovelSelection: () -> Unit, modifier: Modifier = Modifier, ) { var searchQuery by rememberSaveable(stateSaver = TextFieldValue.Saver) { @@ -49,7 +52,7 @@ internal fun CollectionNovelSearchScreen( title = "작품 리스트", actionLabel = "완료", onNavigateBack = onNavigateBack, - isActionEnabled = false, + isActionEnabled = addedNovelCount > 0, ) CollectionNovelSearchField( value = searchQuery, @@ -62,6 +65,11 @@ internal fun CollectionNovelSearchScreen( end = 20.dp, ), ) + CollectionNovelSelectionInfo( + addedNovelCount = addedNovelCount, + onAddFromLibraryClick = onNavigateToLibraryNovelSelection, + modifier = Modifier.padding(top = 16.dp), + ) } } @@ -69,6 +77,10 @@ internal fun CollectionNovelSearchScreen( @Composable private fun CollectionNovelSearchScreenPreview() { WebsosoTheme { - CollectionNovelSearchScreen(onNavigateBack = {}) + CollectionNovelSearchScreen( + addedNovelCount = 0, + onNavigateBack = {}, + onNavigateToLibraryNovelSelection = {}, + ) } } 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 index d8fdb7b43..84dbfbc1f 100644 --- 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 @@ -2,7 +2,9 @@ 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 @@ -27,6 +29,7 @@ internal fun CollectionAppBar( title: String? = null, actionLabel: String? = null, onNavigateBack: () -> Unit, + onActionClick: () -> Unit = {}, isActionEnabled: Boolean = false, modifier: Modifier = Modifier, ) { @@ -58,14 +61,22 @@ internal fun CollectionAppBar( ) } actionLabel?.let { - Text( - text = it, - color = if (isActionEnabled) Primary100 else Gray100, - style = WebsosoTheme.typography.title2, + Box( modifier = Modifier .align(Alignment.CenterEnd) - .padding(horizontal = 20.dp), - ) + .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, + ) + } } } } 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/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/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, +}