diff --git a/core/resource/src/main/res/values/strings.xml b/core/resource/src/main/res/values/strings.xml index ab00c6277..6348fcb83 100644 --- a/core/resource/src/main/res/values/strings.xml +++ b/core/resource/src/main/res/values/strings.xml @@ -459,6 +459,8 @@ 컬렉션에 관련한 설명을 간단하게 작성해주세요 작품 리스트 작품 추가 + 작품 수정 + 대표 추가 삭제 추가한 작품 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 index 7d17230e3..b5dd8fc04 100644 --- 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 @@ -5,6 +5,8 @@ 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.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -20,9 +22,13 @@ 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 +import com.into.websoso.feature.collection.model.CollectionSelectedNovel @Composable internal fun CollectionCreateScreen( + selectedNovels: List, + representativeNovelId: Long?, + onRepresentativeNovelClick: (Long) -> Unit, onNavigateBack: () -> Unit, onNavigateToNovelSearch: () -> Unit, modifier: Modifier = Modifier, @@ -40,37 +46,48 @@ internal fun CollectionCreateScreen( CollectionAppBar( actionLabel = "완료", onNavigateBack = onNavigateBack, + isActionEnabled = collectionName.isNotBlank() && selectedNovels.isNotEmpty(), ) - 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, - ), - ) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + 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( + selectedNovels = selectedNovels, + representativeNovelId = representativeNovelId, + onRepresentativeNovelClick = onRepresentativeNovelClick, + onAddNovelClick = onNavigateToNovelSearch, + modifier = Modifier.padding( + start = 20.dp, + top = 30.dp, + end = 20.dp, + bottom = 20.dp, + ), + ) + } } } @@ -79,6 +96,9 @@ internal fun CollectionCreateScreen( private fun CollectionCreateScreenPreview() { WebsosoTheme { CollectionCreateScreen( + selectedNovels = emptyList(), + representativeNovelId = null, + onRepresentativeNovelClick = {}, onNavigateBack = {}, onNavigateToNovelSearch = {}, ) 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 index 594436510..383799d36 100644 --- 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 @@ -39,7 +39,7 @@ internal class CollectionLibraryNovelSelectionViewModel if (selectedNovels.any { it.novelId == novel.novelId }) { selectedNovels.filterNot { it.novelId == novel.novelId } } else { - selectedNovels + novel.toSelectedNovel() + listOf(novel.toSelectedNovel()) + selectedNovels } } } 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 da8476577..4654b363d 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,9 +1,11 @@ package com.into.websoso.feature.collection import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -34,8 +36,16 @@ fun CollectionNavHost( }, ) } - composable(route = COLLECTION_CREATE_ROUTE) { + composable(route = COLLECTION_CREATE_ROUTE) { backStackEntry -> + val novelSearchViewModel: CollectionNovelSearchViewModel = hiltViewModel(backStackEntry) + val selectedNovels by novelSearchViewModel.selectedNovels.collectAsStateWithLifecycle() + val representativeNovelId by + novelSearchViewModel.representativeNovelId.collectAsStateWithLifecycle() + CollectionCreateScreen( + selectedNovels = selectedNovels, + representativeNovelId = representativeNovelId, + onRepresentativeNovelClick = novelSearchViewModel::updateRepresentativeNovel, onNavigateBack = navController::popBackStack, onNavigateToNovelSearch = { navController.navigate(COLLECTION_NOVEL_SEARCH_ROUTE) @@ -46,7 +56,8 @@ fun CollectionNavHost( val createBackStackEntry = remember(backStackEntry) { navController.getBackStackEntry(COLLECTION_CREATE_ROUTE) } - val novelSearchViewModel: CollectionNovelSearchViewModel = hiltViewModel(createBackStackEntry) + val novelSearchViewModel: CollectionNovelSearchViewModel = + hiltViewModel(createBackStackEntry) CollectionNovelSearchRoute( viewModel = novelSearchViewModel, @@ -60,10 +71,12 @@ fun CollectionNavHost( val createBackStackEntry = remember(backStackEntry) { navController.getBackStackEntry(COLLECTION_CREATE_ROUTE) } - val novelSearchViewModel: CollectionNovelSearchViewModel = hiltViewModel(createBackStackEntry) + val novelSearchViewModel: CollectionNovelSearchViewModel = + hiltViewModel(createBackStackEntry) + val selectedNovels by novelSearchViewModel.selectedNovels.collectAsStateWithLifecycle() CollectionLibraryNovelSelectionRoute( - initialSelectedNovels = novelSearchViewModel.selectedNovels.value, + initialSelectedNovels = selectedNovels, onAddClick = { selectedNovels -> novelSearchViewModel.updateSelectedNovels(selectedNovels) navController.popBackStack() 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 index e62e164af..5719fe7a4 100644 --- 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 @@ -15,7 +15,6 @@ 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 @@ -30,6 +29,9 @@ internal class CollectionNovelSearchViewModel private val _selectedNovels = MutableStateFlow>(emptyList()) val selectedNovels: StateFlow> = _selectedNovels.asStateFlow() + private val _representativeNovelId = MutableStateFlow(null) + val representativeNovelId: StateFlow = _representativeNovelId.asStateFlow() + @OptIn(ExperimentalCoroutinesApi::class) val searchResults: Flow> = submittedQuery @@ -46,23 +48,37 @@ internal class CollectionNovelSearchViewModel } fun addNovel(novel: NovelSearchEntity) { - _selectedNovels.update { selectedNovels -> - if (selectedNovels.any { it.novelId == novel.novelId }) { - selectedNovels - } else { - selectedNovels + novel.toSelectedNovel() - } - } + if (_selectedNovels.value.any { it.novelId == novel.novelId }) return + + _selectedNovels.value = listOf(novel.toSelectedNovel()) + _selectedNovels.value + _representativeNovelId.value = novel.novelId } fun removeNovel(novelId: Long) { - _selectedNovels.update { selectedNovels -> - selectedNovels.filterNot { it.novelId == novelId } + val remainingNovels = _selectedNovels.value.filterNot { it.novelId == novelId } + _selectedNovels.value = remainingNovels + + if (_representativeNovelId.value == novelId) { + _representativeNovelId.value = remainingNovels.firstOrNull()?.novelId } } fun updateSelectedNovels(novels: List) { + val previousNovelIds = _selectedNovels.value.map(CollectionSelectedNovel::novelId) + val novelIds = novels.map(CollectionSelectedNovel::novelId) + val selectedNovelIds = novelIds.toSet() + val onlyRemovedNovels = novelIds == previousNovelIds.filter { it in selectedNovelIds } _selectedNovels.value = novels + + if (!onlyRemovedNovels || _representativeNovelId.value !in selectedNovelIds) { + _representativeNovelId.value = novelIds.firstOrNull() + } + } + + fun updateRepresentativeNovel(novelId: Long) { + if (_selectedNovels.value.any { it.novelId == novelId }) { + _representativeNovelId.value = novelId + } } } 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 index 8fa4429ae..48d44433e 100644 --- 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 @@ -1,12 +1,22 @@ package com.into.websoso.feature.collection.component 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.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +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.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -14,31 +24,43 @@ 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.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextOverflow 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.component.NetworkImage 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 +import com.into.websoso.core.designsystem.theme.White +import com.into.websoso.core.resource.R.drawable.ic_library_sort_check import com.into.websoso.core.resource.R.drawable.ic_plus_novel import com.into.websoso.core.resource.R.string.collection_create_add_novel import com.into.websoso.core.resource.R.string.collection_create_count +import com.into.websoso.core.resource.R.string.collection_create_edit_novel import com.into.websoso.core.resource.R.string.collection_create_novel_list +import com.into.websoso.core.resource.R.string.collection_create_representative +import com.into.websoso.feature.collection.model.CollectionSelectedNovel @Composable internal fun CollectionNovelSection( + selectedNovels: List, + representativeNovelId: Long?, + onRepresentativeNovelClick: (Long) -> Unit, onAddNovelClick: () -> Unit, modifier: Modifier = Modifier, ) { - val addCardShape = RoundedCornerShape(8.dp) val novelList = stringResource(collection_create_novel_list) + Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp), @@ -58,38 +80,183 @@ internal fun CollectionNovelSection( style = WebsosoTheme.typography.title2, ) Text( - text = stringResource(collection_create_count, 0, 100), + text = stringResource(collection_create_count, selectedNovels.size, 100), color = Gray200, style = WebsosoTheme.typography.body3, ) } - Column( + if (selectedNovels.isEmpty()) { + CollectionNovelEditCard( + label = stringResource(collection_create_add_novel), + onClick = onAddNovelClick, + modifier = Modifier.size(width = 103.dp, height = 160.dp), + ) + } else { + Column( + modifier = Modifier.selectableGroup(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + (listOf(null) + selectedNovels) + .chunked(3) + .forEach { rowItems -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + rowItems.forEach { novel -> + if (novel == null) { + CollectionNovelEditCard( + label = stringResource(collection_create_edit_novel), + onClick = onAddNovelClick, + modifier = Modifier + .weight(1f) + .height(156.dp), + ) + } else { + CollectionNovelItem( + novel = novel, + isRepresentative = novel.novelId == representativeNovelId, + onRepresentativeClick = { + onRepresentativeNovelClick(novel.novelId) + }, + modifier = Modifier.weight(1f), + ) + } + } + repeat(3 - rowItems.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + } + } +} + +@Composable +private fun CollectionNovelEditCard( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(8.dp) + + Column( + modifier = modifier + .clip(shape) + .background( + color = Gray50, + shape = shape, + ).clickable( + onClick = onClick, + role = Role.Button, + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = label, + 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), + ) + } +} + +@Composable +private fun CollectionNovelItem( + novel: CollectionSelectedNovel, + isRepresentative: Boolean, + onRepresentativeClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val thumbnailShape = RoundedCornerShape(8.dp) + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( modifier = Modifier - .size(width = 103.dp, height = 160.dp) - .clip(addCardShape) - .background( - color = Gray50, - shape = addCardShape, - ).clickable( - onClick = onAddNovelClick, - role = Role.Button, + .fillMaxWidth() + .height(156.dp) + .clip(thumbnailShape) + .selectable( + selected = isRepresentative, + onClick = onRepresentativeClick, + role = Role.RadioButton, + ).then( + if (isRepresentative) { + Modifier.border( + width = 2.dp, + color = Primary100, + shape = thumbnailShape, + ) + } else { + Modifier + }, ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, ) { - Text( - text = stringResource(collection_create_add_novel), - 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), + NetworkImage( + imageUrl = novel.imageUrl, + contentDescription = novel.title, + contentScale = ContentScale.Crop, + // TODO: 기획·디자인 확인 후 Alignment.Center 적용 여부 재검토 + alignment = Alignment.BottomCenter, + modifier = Modifier.fillMaxSize(), ) + Row( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .size( + width = if (isRepresentative) 40.dp else 32.dp, + height = 18.dp, + ).background( + color = if (isRepresentative) Primary100 else Gray100, + shape = RoundedCornerShape(4.dp), + ), + horizontalArrangement = Arrangement.spacedBy( + space = 2.dp, + alignment = Alignment.CenterHorizontally, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isRepresentative) { + Box( + modifier = Modifier + .size(width = 8.4.dp, height = 6.dp) + .offset(y = (-0.5).dp), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = ic_library_sort_check), + contentDescription = null, + tint = White, + modifier = Modifier.requiredSize(14.4.dp), + ) + } + } + Text( + text = stringResource(collection_create_representative), + color = White, + style = WebsosoTheme.typography.label2, + ) + } } + Text( + text = novel.title, + color = Black, + style = WebsosoTheme.typography.body4, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } @@ -97,6 +264,11 @@ internal fun CollectionNovelSection( @Composable private fun CollectionNovelSectionPreview() { WebsosoTheme { - CollectionNovelSection(onAddNovelClick = {}) + CollectionNovelSection( + selectedNovels = emptyList(), + representativeNovelId = null, + onRepresentativeNovelClick = {}, + onAddNovelClick = {}, + ) } }