diff --git a/app/src/androidTest/java/com/into/websoso/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/into/websoso/ExampleInstrumentedTest.kt deleted file mode 100644 index 426080809..000000000 --- a/app/src/androidTest/java/com/into/websoso/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.into.websoso - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.teamwss.websoso", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/test/java/com/into/websoso/ExampleUnitTest.kt b/app/src/test/java/com/into/websoso/ExampleUnitTest.kt deleted file mode 100644 index 99b42c258..000000000 --- a/app/src/test/java/com/into/websoso/ExampleUnitTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.into.websoso - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file 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/core/resource/src/main/res/values/strings.xml b/core/resource/src/main/res/values/strings.xml index 40ae57467..ab00c6277 100644 --- a/core/resource/src/main/res/values/strings.xml +++ b/core/resource/src/main/res/values/strings.xml @@ -459,6 +459,12 @@ 컬렉션에 관련한 설명을 간단하게 작성해주세요 작품 리스트 작품 추가 + 추가 + 삭제 + 추가한 작품 + %1$d개 + 서재에서 추가 + 작품 제목, 작가를 검색하세요 (%1$d/%2$d) 보는 중 봤어요 diff --git a/data/novel/build.gradle.kts b/data/novel/build.gradle.kts index 2b6edc225..4b49cff1e 100644 --- a/data/novel/build.gradle.kts +++ b/data/novel/build.gradle.kts @@ -15,6 +15,4 @@ dependencies { implementation(libs.paging.runtime) implementation(libs.retrofit) implementation(libs.serialization.json) - - testImplementation(libs.junit) } 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/feature/collection/build.gradle.kts b/feature/collection/build.gradle.kts index 163a265f6..bcdee05eb 100644 --- a/feature/collection/build.gradle.kts +++ b/feature/collection/build.gradle.kts @@ -10,6 +10,7 @@ android { dependencies { implementation(projects.data.library) + implementation(projects.data.novel) 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 index e5f0bd184..a1e334e53 100644 --- 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 @@ -1,34 +1,24 @@ 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 @@ -37,39 +27,36 @@ 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.core.resource.R.string.collection_create_add +import com.into.websoso.core.resource.R.string.library 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( - initialSelectedNovelIds: Set, - onAddClick: (Set) -> Unit, + initialSelectedNovels: List, + onAddClick: (List) -> Unit, onNavigateBack: () -> Unit, viewModel: CollectionLibraryNovelSelectionViewModel = hiltViewModel(), ) { val novels = viewModel.novels.collectAsLazyPagingItems() - val selectedNovelIds by viewModel.selectedNovelIds.collectAsStateWithLifecycle() + val selectedNovels by viewModel.selectedNovels.collectAsStateWithLifecycle() - LaunchedEffect(initialSelectedNovelIds) { - viewModel.setSelectedNovelIds(initialSelectedNovelIds) + LaunchedEffect(initialSelectedNovels) { + viewModel.setSelectedNovels(initialSelectedNovels) } CollectionLibraryNovelSelectionScreen( novels = novels, - selectedNovelIds = selectedNovelIds, + selectedNovelIds = selectedNovels.mapTo(mutableSetOf()) { it.novelId }, onNovelSelectionChange = viewModel::toggleNovelSelection, - onAddClick = { onAddClick(selectedNovelIds) }, + onAddClick = { onAddClick(selectedNovels) }, onNavigateBack = onNavigateBack, ) } @@ -78,7 +65,7 @@ internal fun CollectionLibraryNovelSelectionRoute( internal fun CollectionLibraryNovelSelectionScreen( novels: LazyPagingItems, selectedNovelIds: Set, - onNovelSelectionChange: (Long) -> Unit, + onNovelSelectionChange: (CollectionLibraryNovelUiModel) -> Unit, onAddClick: () -> Unit, onNavigateBack: () -> Unit, modifier: Modifier = Modifier, @@ -90,8 +77,8 @@ internal fun CollectionLibraryNovelSelectionScreen( .statusBarsPadding(), ) { CollectionAppBar( - title = "서재", - actionLabel = "추가", + title = stringResource(library), + actionLabel = stringResource(collection_create_add), onNavigateBack = onNavigateBack, onActionClick = onAddClick, isActionEnabled = selectedNovelIds.isNotEmpty(), @@ -108,44 +95,10 @@ internal fun CollectionLibraryNovelSelectionScreen( (novels.itemCount == 0 && novels.loadState.refresh is LoadState.Error) || novels.loadState.append is LoadState.Error -> { - Column( + CollectionNetworkError( + onRetryClick = novels::retry, 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 -> { @@ -165,7 +118,7 @@ internal fun CollectionLibraryNovelSelectionScreen( CollectionLibraryNovelItem( novel = novel, isSelected = novel.novelId in selectedNovelIds, - onSelectionChange = { onNovelSelectionChange(novel.novelId) }, + onSelectionChange = { onNovelSelectionChange(novel) }, ) } } 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 b3644b9cb..594436510 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 @@ -9,6 +9,7 @@ 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 @@ -24,8 +25,8 @@ internal class CollectionLibraryNovelSelectionViewModel constructor( libraryRepository: LibraryRepository, ) : ViewModel() { - private val _selectedNovelIds = MutableStateFlow>(emptySet()) - val selectedNovelIds: StateFlow> = _selectedNovelIds.asStateFlow() + private val _selectedNovels = MutableStateFlow>(emptyList()) + val selectedNovels: StateFlow> = _selectedNovels.asStateFlow() val novels: Flow> = libraryRepository @@ -33,17 +34,25 @@ internal class CollectionLibraryNovelSelectionViewModel .map { pagingData -> pagingData.map(NovelEntity::toUiModel) } .cachedIn(viewModelScope) - fun toggleNovelSelection(novelId: Long) { - _selectedNovelIds.update { selectedNovelIds -> - if (novelId in selectedNovelIds) { - selectedNovelIds - novelId + fun toggleNovelSelection(novel: CollectionLibraryNovelUiModel) { + _selectedNovels.update { selectedNovels -> + if (selectedNovels.any { it.novelId == novel.novelId }) { + selectedNovels.filterNot { it.novelId == novel.novelId } } else { - selectedNovelIds + novelId + selectedNovels + novel.toSelectedNovel() } } } - fun setSelectedNovelIds(novelIds: Set) { - _selectedNovelIds.value = novelIds + 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 index f6dc8c7ae..da8476577 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,9 @@ 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.lifecycle.compose.collectAsStateWithLifecycle +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -13,7 +13,6 @@ 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( @@ -44,31 +43,29 @@ fun CollectionNavHost( ) } composable(route = COLLECTION_NOVEL_SEARCH_ROUTE) { backStackEntry -> - val selectedNovelIds by backStackEntry.savedStateHandle - .getStateFlow(SELECTED_NOVEL_IDS_KEY, longArrayOf()) - .collectAsStateWithLifecycle() + val createBackStackEntry = remember(backStackEntry) { + navController.getBackStackEntry(COLLECTION_CREATE_ROUTE) + } + val novelSearchViewModel: CollectionNovelSearchViewModel = hiltViewModel(createBackStackEntry) - CollectionNovelSearchScreen( - addedNovelCount = selectedNovelIds.size, + CollectionNovelSearchRoute( + viewModel = novelSearchViewModel, 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() + composable(route = COLLECTION_LIBRARY_NOVEL_SELECTION_ROUTE) { backStackEntry -> + val createBackStackEntry = remember(backStackEntry) { + navController.getBackStackEntry(COLLECTION_CREATE_ROUTE) + } + val novelSearchViewModel: CollectionNovelSearchViewModel = hiltViewModel(createBackStackEntry) CollectionLibraryNovelSelectionRoute( - initialSelectedNovelIds = initialSelectedNovelIds, - onAddClick = { selectedNovelIds -> - navController.previousBackStackEntry - ?.savedStateHandle - ?.set(SELECTED_NOVEL_IDS_KEY, selectedNovelIds.toLongArray()) + 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 index 52262404f..eb8c0faab 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 @@ -1,10 +1,24 @@ 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 @@ -12,21 +26,80 @@ 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.collection_create_complete +import com.into.websoso.core.resource.R.string.collection_create_novel_list +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( - addedNovelCount: Int, + searchResults: LazyPagingItems, + submittedQuery: String, + selectedNovels: List, + onSearch: (String) -> Unit, + onAddNovel: (NovelSearchEntity) -> Unit, + onDeleteNovel: (Long) -> Unit, onNavigateBack: () -> Unit, onNavigateToLibraryNovelSelection: () -> Unit, modifier: Modifier = Modifier, @@ -36,6 +109,11 @@ internal fun CollectionNovelSearchScreen( } val searchFocusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current + val selectedNovelIds = selectedNovels.mapTo(mutableSetOf()) { it.novelId } + val isInitialLoading = + submittedQuery.isNotBlank() && + searchResults.itemCount == 0 && + searchResults.loadState.refresh is LoadState.Loading LaunchedEffect(Unit) { searchFocusRequester.requestFocus() @@ -49,15 +127,23 @@ internal fun CollectionNovelSearchScreen( .statusBarsPadding(), ) { CollectionAppBar( - title = "작품 리스트", - actionLabel = "완료", + title = stringResource(collection_create_novel_list), + actionLabel = stringResource(collection_create_complete), onNavigateBack = onNavigateBack, - isActionEnabled = addedNovelCount > 0, + onActionClick = onNavigateBack, + isActionEnabled = selectedNovels.isNotEmpty(), ) CollectionNovelSearchField( value = searchQuery, onValueChange = { searchQuery = it }, - onClearClick = { searchQuery = TextFieldValue() }, + onClearClick = { + searchQuery = TextFieldValue() + onSearch("") + }, + onSearchClick = { + keyboardController?.hide() + onSearch(searchQuery.text) + }, focusRequester = searchFocusRequester, modifier = Modifier.padding( start = 20.dp, @@ -66,10 +152,129 @@ internal fun CollectionNovelSearchScreen( ), ) CollectionNovelSelectionInfo( - addedNovelCount = addedNovelCount, + 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), + ) + } + + isInitialLoading -> { + 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, + ) + } } } @@ -78,7 +283,12 @@ internal fun CollectionNovelSearchScreen( private fun CollectionNovelSearchScreenPreview() { WebsosoTheme { CollectionNovelSearchScreen( - addedNovelCount = 0, + 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/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 index e0a8bcc56..d75982640 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -10,6 +11,7 @@ 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 @@ -21,6 +23,8 @@ 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.res.stringResource +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 @@ -32,12 +36,14 @@ 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 +import com.into.websoso.core.resource.R.string.collection_create_search_hint @Composable internal fun CollectionNovelSearchField( value: TextFieldValue, onValueChange: (TextFieldValue) -> Unit, onClearClick: () -> Unit, + onSearchClick: () -> Unit, focusRequester: FocusRequester, modifier: Modifier = Modifier, ) { @@ -65,11 +71,12 @@ internal fun CollectionNovelSearchField( 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 = "작품 제목, 작가를 검색하세요", + text = stringResource(collection_create_search_hint), color = Gray100, style = WebsosoTheme.typography.body4, ) @@ -85,13 +92,18 @@ internal fun CollectionNovelSearchField( ) { Image( painter = painterResource(id = ic_common_search_clear), - contentDescription = "검색어 지우기", + contentDescription = null, modifier = Modifier.size(36.dp), ) } } Box( - modifier = Modifier.size(36.dp), + modifier = Modifier + .size(36.dp) + .clickable( + onClick = onSearchClick, + role = Role.Button, + ), contentAlignment = Alignment.Center, ) { Image( @@ -111,6 +123,7 @@ private fun CollectionNovelSearchFieldPreview() { 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..27cb19c1e --- /dev/null +++ b/feature/collection/src/main/java/com/into/websoso/feature/collection/component/CollectionNovelSearchItem.kt @@ -0,0 +1,143 @@ +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.res.stringResource +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.core.resource.R.string.collection_create_add +import com.into.websoso.core.resource.R.string.collection_create_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, + isAdd: Boolean, + 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)) + 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 = stringResource(if (isAdd) collection_create_add else collection_create_delete), + 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, + isAdd = true, + 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, + isAdd = false, + onActionClick = onDeleteClick, + modifier = modifier, + ) +} 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 index f2ff57cb9..e2d153381 100644 --- 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 @@ -9,6 +9,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview @@ -16,6 +17,9 @@ 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 +import com.into.websoso.core.resource.R.string.collection_create_add_from_library +import com.into.websoso.core.resource.R.string.collection_create_added_novel_count +import com.into.websoso.core.resource.R.string.collection_create_added_novels @Composable internal fun CollectionNovelSelectionInfo( @@ -32,18 +36,18 @@ internal fun CollectionNovelSelectionInfo( ) { Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { Text( - text = "추가한 작품", + text = stringResource(collection_create_added_novels), color = Gray200, style = WebsosoTheme.typography.body4, ) Text( - text = "${addedNovelCount}개", + text = stringResource(collection_create_added_novel_count, addedNovelCount), color = Primary100, style = WebsosoTheme.typography.body4, ) } Text( - text = "서재에서 추가", + text = stringResource(collection_create_add_from_library), color = Gray200, style = WebsosoTheme.typography.body4.copy( textDecoration = TextDecoration.Underline, 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3405ee8af..62d014da9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,7 @@ google-services = "4.4.4" firebase-bom = "34.7.0" # Analytics -amplitude = "1.+" +amplitude = "1.30.1" # AndroidX Libraries androidx = "1.15.0"