From 8bf70379dd5e267f49380ea7d8fb250241f50216 Mon Sep 17 00:00:00 2001 From: chanmi1125 Date: Sun, 13 Sep 2026 23:10:51 +0900 Subject: [PATCH 1/6] =?UTF-8?q?[feat]=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../collectioncreate/CollectionCreateScreen.kt | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateScreen.kt b/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateScreen.kt index 521cad82..99c858f4 100644 --- a/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateScreen.kt @@ -122,6 +122,7 @@ fun CollectionCreateRoute( } var pendingContentId by rememberSaveable { mutableStateOf(null) } + var contentImageLimitReachedCount by remember { mutableStateOf(0) } val onContentImagesPicked: (List) -> Unit = { uris -> pendingContentId?.let { contentId -> viewModel.addContentImageUris(contentId, uris) } @@ -161,11 +162,7 @@ fun CollectionCreateRoute( val remainingSlots = (uiState.contentDetailsMap[contentId] ?: ContentDetail()).remainingImageSlots val imageOnlyRequest = PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) when { - remainingSlots == 0 -> Toast.makeText( - context, - "작품 이미지는 최대 ${MAX_CONTENT_IMAGE_COUNT}개까지 추가할 수 있어요", - Toast.LENGTH_SHORT, - ).show() + remainingSlots == 0 -> contentImageLimitReachedCount++ remainingSlots == 1 -> { pendingContentId = contentId @@ -180,6 +177,7 @@ fun CollectionCreateRoute( }, onRemoveExistingContentImage = viewModel::removeExistingContentImageUrl, onRemoveContentImage = viewModel::removeContentImageUri, + contentImageLimitReachedCount = contentImageLimitReachedCount, modifier = Modifier.padding(paddingValues), ) } @@ -202,6 +200,7 @@ fun CollectionCreateScreen( onSelectContentImage: (contentId: String) -> Unit = {}, onRemoveExistingContentImage: (contentId: String, index: Int) -> Unit = { _, _ -> }, onRemoveContentImage: (contentId: String, index: Int) -> Unit = { _, _ -> }, + contentImageLimitReachedCount: Int = 0, modifier: Modifier = Modifier, ) { var isModalVisible by remember { mutableStateOf(false) } @@ -234,6 +233,12 @@ fun CollectionCreateScreen( } } + LaunchedEffect(contentImageLimitReachedCount) { + if (contentImageLimitReachedCount > 0) { + showToast("작품 이미지는 최대 ${MAX_CONTENT_IMAGE_COUNT}개까지 추가할 수 있어요") + } + } + val lazyListState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() @@ -403,8 +408,7 @@ fun CollectionCreateScreen( key = toastRequestId, imageVector = ImageVector.vectorResource(R.drawable.ic_toast_error), paddingValues = PaddingValues.Zero, - yOffset = 120.dp, - imeYOffset = 16.dp, + yOffset = 16.dp, hide = { toastMessage = null }, ) } From 5b2c98ba7f0ee679e53e32710c104f64d34dbfa8 Mon Sep 17 00:00:00 2001 From: chanmi1125 Date: Sun, 13 Sep 2026 23:28:34 +0900 Subject: [PATCH 2/6] =?UTF-8?q?[feat]=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B2=84=ED=8A=BC=20=EC=A0=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/CollectionCreateContentReason.kt | 4 ++-- app/src/main/res/drawable/ic_add_photo.xml | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 app/src/main/res/drawable/ic_add_photo.xml diff --git a/app/src/main/java/com/flint/android/presentation/collectioncreate/component/CollectionCreateContentReason.kt b/app/src/main/java/com/flint/android/presentation/collectioncreate/component/CollectionCreateContentReason.kt index ed6e82ea..72291605 100644 --- a/app/src/main/java/com/flint/android/presentation/collectioncreate/component/CollectionCreateContentReason.kt +++ b/app/src/main/java/com/flint/android/presentation/collectioncreate/component/CollectionCreateContentReason.kt @@ -91,10 +91,10 @@ fun CollectionCreateContentReason( contentAlignment = Alignment.Center, ) { Icon( - painter = painterResource(R.drawable.ic_background_photo), + painter = painterResource(R.drawable.ic_add_photo), contentDescription = null, tint = Color.Unspecified, - modifier = Modifier.size(14.dp), + modifier = Modifier.size(24.dp), ) } Row( diff --git a/app/src/main/res/drawable/ic_add_photo.xml b/app/src/main/res/drawable/ic_add_photo.xml new file mode 100644 index 00000000..0be4e18f --- /dev/null +++ b/app/src/main/res/drawable/ic_add_photo.xml @@ -0,0 +1,13 @@ + + + From 9cb2aa98777b00d79195d489d6d087874908b431 Mon Sep 17 00:00:00 2001 From: chanmi1125 Date: Wed, 16 Sep 2026 00:29:19 +0900 Subject: [PATCH 3/6] =?UTF-8?q?[feat]=20=EC=9E=91=ED=92=88=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B6=81=EB=A7=88=ED=81=AC=20=EC=9E=91=ED=92=88=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EB=9D=84=EC=9A=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/flint/android/data/api/SearchApi.kt | 8 --- .../BookmarkedContentListResponseDto.kt | 3 + .../SearchBookmarkedContentsResponseDto.kt | 44 -------------- .../domain/mapper/content/ContentMapper.kt | 16 +---- .../mapper/search/SearchContentMapper.kt | 1 - .../content/BookmarkedContentListModel.kt | 4 +- .../domain/repository/ContentRepository.kt | 17 ++++++ .../domain/repository/SearchRepository.kt | 5 -- .../collectioncreate/AddContentScreen.kt | 45 ++++++++++++++ .../CollectionCreateViewModel.kt | 58 +++++++++++++++++-- .../component/AddContentSelectItem.kt | 6 +- .../uistate/CollectionCreateUiState.kt | 4 ++ 12 files changed, 131 insertions(+), 80 deletions(-) delete mode 100644 app/src/main/java/com/flint/android/data/dto/search/SearchBookmarkedContentsResponseDto.kt diff --git a/app/src/main/java/com/flint/android/data/api/SearchApi.kt b/app/src/main/java/com/flint/android/data/api/SearchApi.kt index f73d1cf8..fa6f9e7f 100644 --- a/app/src/main/java/com/flint/android/data/api/SearchApi.kt +++ b/app/src/main/java/com/flint/android/data/api/SearchApi.kt @@ -1,19 +1,11 @@ package com.flint.android.data.api import com.flint.android.data.dto.base.BaseResponse -import com.flint.android.data.dto.search.SearchBookmarkedContentsResponseDto import com.flint.android.data.dto.search.SearchContentsResponseDto import retrofit2.http.GET import retrofit2.http.Query interface SearchApi { - @GET("/api/v1/search/bookmarked-contents") - suspend fun getBookmarkedContentList( - @Query("keyword") keyword: String, - @Query("cursor") cursor: Int, - @Query("size") size: Int - ) : BaseResponse - @GET("/api/v1/contents/search") suspend fun getSearchContentList( @Query("keyword") keyword: String? = null, diff --git a/app/src/main/java/com/flint/android/data/dto/content/response/BookmarkedContentListResponseDto.kt b/app/src/main/java/com/flint/android/data/dto/content/response/BookmarkedContentListResponseDto.kt index ac3c656f..b77ac845 100644 --- a/app/src/main/java/com/flint/android/data/dto/content/response/BookmarkedContentListResponseDto.kt +++ b/app/src/main/java/com/flint/android/data/dto/content/response/BookmarkedContentListResponseDto.kt @@ -44,6 +44,9 @@ data class BookmarkedContentResponseDto( val id: String, @SerialName("title") val title: String, + // 영화 감독 또는 TV creator. 정보가 없으면 null + @SerialName("author") + val author: String? = null, @SerialName("year") val year: Int, @SerialName("imageUrl") diff --git a/app/src/main/java/com/flint/android/data/dto/search/SearchBookmarkedContentsResponseDto.kt b/app/src/main/java/com/flint/android/data/dto/search/SearchBookmarkedContentsResponseDto.kt deleted file mode 100644 index a7ba79d1..00000000 --- a/app/src/main/java/com/flint/android/data/dto/search/SearchBookmarkedContentsResponseDto.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.flint.android.data.dto.search - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class SearchBookmarkedContentsResponseDto( - @SerialName("data") - val data: List, - @SerialName("meta") - val meta: List -) - -@Serializable -data class SearchBookmarkedContentsItemResponseDto( - @SerialName("bookmarkId") - val bookmarkId: Long, - @SerialName("contentId") - val contentId: String, - @SerialName("title") - val title: String, - @SerialName("author") - val author: String, - @SerialName("posterUrl") - val posterUrl: String, - @SerialName("year") - val year: Int -) - -@Serializable -data class SearchBookmarkedContentsMetaItemResponseDto( - @SerialName("type") - val type: String, - @SerialName("returned") - val returned: Int, - @SerialName("currentPage") - val currentPage: Int, - @SerialName("totalPages") - val totalPages: Int, - @SerialName("totalElements") - val totalElements: Int, - @SerialName("nextCursor") - val nextCursor: String, -) \ No newline at end of file diff --git a/app/src/main/java/com/flint/android/domain/mapper/content/ContentMapper.kt b/app/src/main/java/com/flint/android/domain/mapper/content/ContentMapper.kt index 81adc604..10711f8e 100644 --- a/app/src/main/java/com/flint/android/domain/mapper/content/ContentMapper.kt +++ b/app/src/main/java/com/flint/android/domain/mapper/content/ContentMapper.kt @@ -3,10 +3,8 @@ package com.flint.android.domain.mapper.content import com.flint.android.data.dto.content.response.BookmarkedContentListResponseDto import com.flint.android.data.dto.content.response.BookmarkedContentResponseDto import com.flint.android.data.dto.content.response.OttSimpleResponseDto -import com.flint.android.data.dto.search.SearchBookmarkedContentsResponseDto import com.flint.android.domain.model.content.BookmarkedContentItemModel import com.flint.android.domain.model.content.BookmarkedContentListModel -import com.flint.android.domain.model.content.ContentModel import com.flint.android.domain.type.OttType import kotlinx.collections.immutable.toImmutableList import timber.log.Timber @@ -23,6 +21,7 @@ fun BookmarkedContentResponseDto.toModel() : BookmarkedContentItemModel { return BookmarkedContentItemModel( id = id, title = title, + author = author, year = year, imageUrl = imageUrl, bookmarkCount = bookmarkCount, @@ -34,16 +33,3 @@ fun BookmarkedContentResponseDto.toModel() : BookmarkedContentItemModel { } ) } - -fun SearchBookmarkedContentsResponseDto.toModel() : List { - return data.map { - ContentModel( - bookmarkId = it.bookmarkId, - contentId = it.contentId, - title = it.title, - author = it.author, - posterImage = it.posterUrl, - year = it.year - ) - } -} diff --git a/app/src/main/java/com/flint/android/domain/mapper/search/SearchContentMapper.kt b/app/src/main/java/com/flint/android/domain/mapper/search/SearchContentMapper.kt index 63b044e4..c8bee14f 100644 --- a/app/src/main/java/com/flint/android/domain/mapper/search/SearchContentMapper.kt +++ b/app/src/main/java/com/flint/android/domain/mapper/search/SearchContentMapper.kt @@ -23,4 +23,3 @@ private fun SearchContentsResponseDto.Content.toModel(): SearchContentItemModel{ } - diff --git a/app/src/main/java/com/flint/android/domain/model/content/BookmarkedContentListModel.kt b/app/src/main/java/com/flint/android/domain/model/content/BookmarkedContentListModel.kt index 5a781cfc..a0353c9f 100644 --- a/app/src/main/java/com/flint/android/domain/model/content/BookmarkedContentListModel.kt +++ b/app/src/main/java/com/flint/android/domain/model/content/BookmarkedContentListModel.kt @@ -6,7 +6,8 @@ import kotlinx.collections.immutable.persistentListOf data class BookmarkedContentListModel( val totalCount: Int = 0, - val contents: ImmutableList = persistentListOf() + val contents: ImmutableList = persistentListOf(), + val nextCursor: String? = null, ) { companion object { val FakeList = BookmarkedContentListModel( @@ -33,6 +34,7 @@ data class BookmarkedContentListModel( data class BookmarkedContentItemModel( val id: String = "", val title: String = "", + val author: String? = null, val year: Int = 0, val imageUrl: String = "", val bookmarkCount: Int = 0, diff --git a/app/src/main/java/com/flint/android/domain/repository/ContentRepository.kt b/app/src/main/java/com/flint/android/domain/repository/ContentRepository.kt index 1d61267c..fc6821fe 100644 --- a/app/src/main/java/com/flint/android/domain/repository/ContentRepository.kt +++ b/app/src/main/java/com/flint/android/domain/repository/ContentRepository.kt @@ -2,8 +2,11 @@ package com.flint.android.domain.repository import com.flint.android.core.common.util.suspendRunCatching import com.flint.android.data.api.ContentApi +import com.flint.android.domain.mapper.content.toModel import com.flint.android.domain.mapper.ott.toModel +import com.flint.android.domain.model.content.BookmarkedContentListModel import com.flint.android.domain.model.ott.OttListModel +import kotlinx.collections.immutable.toImmutableList import javax.inject.Inject class ContentRepository @Inject constructor( @@ -13,5 +16,19 @@ class ContentRepository @Inject constructor( suspend fun getOttListPerContent(contentId: String) : Result = suspendRunCatching { apiService.getOttListPerContent(contentId).data.toModel() } + // 내 북마크한 콘텐츠 목록 한 페이지 조회 (커서 페이지네이션, 무한 스크롤용) + suspend fun getBookmarkedContentList(cursor: String?, size: Int): Result = + suspendRunCatching { + val page = apiService.getBookmarkedContentList(cursor = cursor, size = size).data + BookmarkedContentListModel( + contents = page.data.map { it.toModel() }.toImmutableList(), + nextCursor = page.meta.nextCursor, + ) + } + + // 내 북마크한 콘텐츠 전체 개수 조회 + suspend fun getBookmarkedContentCount(): Result = + suspendRunCatching { apiService.getBookmarkedContentCount().data.totalCount } + // 콘텐츠 검색 } diff --git a/app/src/main/java/com/flint/android/domain/repository/SearchRepository.kt b/app/src/main/java/com/flint/android/domain/repository/SearchRepository.kt index 74009002..5f165ce7 100644 --- a/app/src/main/java/com/flint/android/domain/repository/SearchRepository.kt +++ b/app/src/main/java/com/flint/android/domain/repository/SearchRepository.kt @@ -2,18 +2,13 @@ package com.flint.android.domain.repository import com.flint.android.core.common.util.suspendRunCatching import com.flint.android.data.api.SearchApi -import com.flint.android.domain.mapper.content.toModel import com.flint.android.domain.mapper.search.toModel -import com.flint.android.domain.model.content.ContentModel import com.flint.android.domain.model.search.SearchContentListModel import javax.inject.Inject class SearchRepository @Inject constructor( private val apiService: SearchApi, ) { - suspend fun getBookmarkedContentList(keyword: String, cursor: Int, size: Int): Result> = - suspendRunCatching { apiService.getBookmarkedContentList(keyword, cursor, size).data.toModel() } - suspend fun getSearchContentList( keyword: String? = null, genres: List? = null, diff --git a/app/src/main/java/com/flint/android/presentation/collectioncreate/AddContentScreen.kt b/app/src/main/java/com/flint/android/presentation/collectioncreate/AddContentScreen.kt index 337c9076..f564cc49 100644 --- a/app/src/main/java/com/flint/android/presentation/collectioncreate/AddContentScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/collectioncreate/AddContentScreen.kt @@ -5,6 +5,7 @@ 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.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -16,9 +17,11 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -63,6 +66,7 @@ fun AddContentRoute( onSearchTextChanged = viewModel::updateSearch, onToggleContent = viewModel::toggleContent, onRemoveContent = viewModel::removeContent, + onLoadMore = viewModel::loadMoreBookmarkedContents, onBackClick = { viewModel.resetSearchText() navigateUp() @@ -83,6 +87,7 @@ fun AddContentScreen( onSearchTextChanged: (String) -> Unit = {}, onToggleContent: (SearchContentItemModel) -> Unit = {}, onRemoveContent: (SearchContentItemModel) -> Unit, + onLoadMore: () -> Unit = {}, onBackClick: () -> Unit, onActionClick: () -> Unit, modifier: Modifier = Modifier @@ -102,6 +107,21 @@ fun AddContentScreen( val isEmptyViewShown = contentList.isEmpty() && uiState.searchText.isNotBlank() + val shouldLoadMore by remember { + derivedStateOf { + val layoutInfo = lazyColumnState.layoutInfo + val totalItems = layoutInfo.totalItemsCount + val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + totalItems > 0 && lastVisibleIndex >= totalItems - 5 + } + } + + LaunchedEffect(shouldLoadMore, uiState.searchText) { + if (shouldLoadMore && uiState.searchText.isBlank()) { + onLoadMore() + } + } + Box( modifier = modifier .fillMaxSize() @@ -179,11 +199,35 @@ fun AddContentScreen( contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { + if (uiState.searchText.isBlank()) { + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Bottom, + ) { + Text( + text = "저장한 작품", + color = FlintTheme.colors.white, + style = FlintTheme.typography.head3M18, + ) + uiState.savedContentCount?.let { count -> + Text( + text = "총 ${count}개", + color = FlintTheme.colors.gray300, + style = FlintTheme.typography.body1M16, + ) + } + } + } + } + items( items = contentList, key = { it.id }, ) { content -> val isSelected = selectedContents.any { it.id == content.id } + val isMaxReached = selectedContents.size >= MAX_CONTENT_COUNT AddContentSelectItem( onCheckClick = { @@ -197,6 +241,7 @@ fun AddContentScreen( } else onToggleContent(content) }, isSelected = isSelected, + isMaxReached = isMaxReached, posterImageUrl = content.posterUrl, title = content.title, director = content.author, diff --git a/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateViewModel.kt b/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateViewModel.kt index 020f2367..07c2fb5f 100644 --- a/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateViewModel.kt +++ b/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateViewModel.kt @@ -11,8 +11,10 @@ import com.flint.android.core.common.util.UiState import com.flint.android.domain.mapper.collection.toDto import com.flint.android.domain.model.collection.CollectionCreateContentModel import com.flint.android.domain.model.collection.CollectionCreateRequestModel +import com.flint.android.domain.model.content.BookmarkedContentItemModel import com.flint.android.domain.model.search.SearchContentItemModel import com.flint.android.domain.repository.CollectionRepository +import com.flint.android.domain.repository.ContentRepository import com.flint.android.domain.repository.SearchRepository import com.flint.android.domain.repository.StorageRepository import com.flint.android.domain.type.FileExtension @@ -38,6 +40,7 @@ import javax.inject.Inject const val MAX_CONTENT_IMAGE_COUNT = 5 const val MAX_CONTENT_COUNT = 10 +private const val BOOKMARKED_CONTENT_PAGE_SIZE = 20 @HiltViewModel class CollectionCreateViewModel @Inject constructor( @@ -45,6 +48,7 @@ class CollectionCreateViewModel @Inject constructor( @ApplicationContext private val context: Context, private val collectionRepository: CollectionRepository, private val searchRepository: SearchRepository, + private val contentRepository: ContentRepository, private val storageRepository: StorageRepository, private val analyticsTracker: AnalyticsTracker, ) : ViewModel() { @@ -62,7 +66,7 @@ class CollectionCreateViewModel @Inject constructor( init { observeSearchQuery() - loadInitialContents() + loadBookmarkedContents() if (editingCollectionId != null) { loadCollectionForEdit(editingCollectionId) } @@ -332,6 +336,13 @@ class CollectionCreateViewModel @Inject constructor( .map { it.trim() } .distinctUntilChanged() .collectLatest { query -> + // 검색어가 없을 때는 전체 검색 API가 아니라, 사용자가 저장한 작품을 + // 최신순으로 내려주는 북마크 전용 API를 써야 한다. + if (query.isBlank()) { + loadBookmarkedContents() + return@collectLatest + } + searchRepository.getSearchContentList(query) .onSuccess { model -> _uiState.update { it.copy(contents = model.contents) } @@ -343,18 +354,55 @@ class CollectionCreateViewModel @Inject constructor( } } - private fun loadInitialContents() { + private fun loadBookmarkedContents() { viewModelScope.launch { - searchRepository.getSearchContentList("") + contentRepository.getBookmarkedContentList(cursor = null, size = BOOKMARKED_CONTENT_PAGE_SIZE) .onSuccess { model -> - _uiState.update { it.copy(contents = model.contents) } + val mapped = model.contents.map { c -> c.toSearchContentItemModel() }.toImmutableList() + _uiState.update { it.copy(contents = mapped, nextCursor = model.nextCursor) } } .onFailure { - _uiState.update { it.copy(contents = persistentListOf()) } + _uiState.update { it.copy(contents = persistentListOf(), nextCursor = null) } } + + contentRepository.getBookmarkedContentCount() + .onSuccess { count -> _uiState.update { it.copy(savedContentCount = count) } } + .onFailure { _uiState.update { it.copy(savedContentCount = null) } } } } + fun loadMoreBookmarkedContents() { + val state = _uiState.value + if (state.searchText.isNotBlank() || state.isLoadingMore) return + val cursor = state.nextCursor ?: return + + viewModelScope.launch { + _uiState.update { it.copy(isLoadingMore = true) } + contentRepository.getBookmarkedContentList(cursor = cursor, size = BOOKMARKED_CONTENT_PAGE_SIZE) + .onSuccess { model -> + _uiState.update { + it.copy( + contents = (it.contents + model.contents.map { c -> c.toSearchContentItemModel() }).toImmutableList(), + nextCursor = model.nextCursor, + isLoadingMore = false, + ) + } + } + .onFailure { + _uiState.update { it.copy(isLoadingMore = false) } + } + } + } + + private fun BookmarkedContentItemModel.toSearchContentItemModel(): SearchContentItemModel = + SearchContentItemModel( + id = id, + title = title, + author = author ?: "", + posterUrl = imageUrl, + year = year, + ) + fun updateThumbnailImageUri(uri: Uri?) { _uiState.update { it.copy(thumbnailImageUri = uri) } } diff --git a/app/src/main/java/com/flint/android/presentation/collectioncreate/component/AddContentSelectItem.kt b/app/src/main/java/com/flint/android/presentation/collectioncreate/component/AddContentSelectItem.kt index 35475a2f..80c23b09 100644 --- a/app/src/main/java/com/flint/android/presentation/collectioncreate/component/AddContentSelectItem.kt +++ b/app/src/main/java/com/flint/android/presentation/collectioncreate/component/AddContentSelectItem.kt @@ -30,6 +30,7 @@ fun AddContentSelectItem( director: String, createdYear: Int, modifier: Modifier = Modifier, + isMaxReached: Boolean = false, ) { Row( modifier = modifier @@ -47,6 +48,8 @@ fun AddContentSelectItem( AddContentSelectItemTag( isSelected = isSelected, + // 10개 선택 시, 이미 선택된 항목이 아니면 버튼이 터치에 반응하지 않는다. + enabled = isSelected || !isMaxReached, onClick = onCheckClick, ) } @@ -56,6 +59,7 @@ fun AddContentSelectItem( fun AddContentSelectItemTag( isSelected: Boolean, onClick: () -> Unit, + enabled: Boolean = true, ) { Icon( imageVector = ImageVector.vectorResource(if (isSelected) R.drawable.ic_check_fill else R.drawable.ic_check_empty), @@ -64,7 +68,7 @@ fun AddContentSelectItemTag( modifier = Modifier .size(48.dp) - .flintIconClickable(onClick = onClick), + .flintIconClickable(enabled = enabled, onClick = onClick), ) } diff --git a/app/src/main/java/com/flint/android/presentation/collectioncreate/uistate/CollectionCreateUiState.kt b/app/src/main/java/com/flint/android/presentation/collectioncreate/uistate/CollectionCreateUiState.kt index e960beab..1d99f566 100644 --- a/app/src/main/java/com/flint/android/presentation/collectioncreate/uistate/CollectionCreateUiState.kt +++ b/app/src/main/java/com/flint/android/presentation/collectioncreate/uistate/CollectionCreateUiState.kt @@ -16,6 +16,10 @@ data class CollectionCreateUiState( val selectedContents: ImmutableList = persistentListOf(), val contentDetailsMap: Map = emptyMap(), val contents: ImmutableList = persistentListOf(), + val savedContentCount: Int? = null, + // 저장한 작품 목록(무한 스크롤)의 다음 페이지 커서. null이면 마지막 페이지. + val nextCursor: String? = null, + val isLoadingMore: Boolean = false, val searchText: String = "", val isLoading: Boolean = false, // 수정 모드 여부의 단일 기준. editingCollectionId(라우트 인자) 존재 여부로 ViewModel이 최초 설정하며, From 84a826464ecb94074ed056a82e8f6d0e49f4d544 Mon Sep 17 00:00:00 2001 From: chanmi1125 Date: Thu, 17 Sep 2026 10:32:18 +0900 Subject: [PATCH 4/6] =?UTF-8?q?[feat]=20=EC=B2=B4=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr_checker.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pr_checker.yml b/.github/workflows/pr_checker.yml index 49709154..fd0fe975 100644 --- a/.github/workflows/pr_checker.yml +++ b/.github/workflows/pr_checker.yml @@ -63,6 +63,8 @@ jobs: - name: Set up Android SDK uses: android-actions/setup-android@v3 + with: + packages: 'platform-tools' - name: Create local.properties env: From 07ec21b198b11d6a522f56087e7072080c07d4f1 Mon Sep 17 00:00:00 2001 From: chanmi1125 Date: Thu, 17 Sep 2026 10:41:54 +0900 Subject: [PATCH 5/6] =?UTF-8?q?[feat]=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../collectioncreate/AddContentScreen.kt | 2 +- .../CollectionCreateViewModel.kt | 48 +++++++++++-------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/flint/android/presentation/collectioncreate/AddContentScreen.kt b/app/src/main/java/com/flint/android/presentation/collectioncreate/AddContentScreen.kt index f564cc49..013d66df 100644 --- a/app/src/main/java/com/flint/android/presentation/collectioncreate/AddContentScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/collectioncreate/AddContentScreen.kt @@ -116,7 +116,7 @@ fun AddContentScreen( } } - LaunchedEffect(shouldLoadMore, uiState.searchText) { + LaunchedEffect(shouldLoadMore, uiState.searchText, uiState.nextCursor) { if (shouldLoadMore && uiState.searchText.isBlank()) { onLoadMore() } diff --git a/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateViewModel.kt b/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateViewModel.kt index 07c2fb5f..6c77b383 100644 --- a/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateViewModel.kt +++ b/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateViewModel.kt @@ -66,7 +66,6 @@ class CollectionCreateViewModel @Inject constructor( init { observeSearchQuery() - loadBookmarkedContents() if (editingCollectionId != null) { loadCollectionForEdit(editingCollectionId) } @@ -354,21 +353,21 @@ class CollectionCreateViewModel @Inject constructor( } } - private fun loadBookmarkedContents() { - viewModelScope.launch { - contentRepository.getBookmarkedContentList(cursor = null, size = BOOKMARKED_CONTENT_PAGE_SIZE) - .onSuccess { model -> - val mapped = model.contents.map { c -> c.toSearchContentItemModel() }.toImmutableList() - _uiState.update { it.copy(contents = mapped, nextCursor = model.nextCursor) } - } - .onFailure { - _uiState.update { it.copy(contents = persistentListOf(), nextCursor = null) } - } + // collectLatest 블록 안에서 직접 호출되어야 검색어 변경 시 진행 중인 요청이 취소된다. + // viewModelScope.launch로 새 코루틴을 띄우면 그 취소 대상에서 벗어나 버린다. + private suspend fun loadBookmarkedContents() { + contentRepository.getBookmarkedContentList(cursor = null, size = BOOKMARKED_CONTENT_PAGE_SIZE) + .onSuccess { model -> + val mapped = model.contents.map { c -> c.toSearchContentItemModel() }.toImmutableList() + _uiState.update { it.copy(contents = mapped, nextCursor = model.nextCursor) } + } + .onFailure { + _uiState.update { it.copy(contents = persistentListOf(), nextCursor = null) } + } - contentRepository.getBookmarkedContentCount() - .onSuccess { count -> _uiState.update { it.copy(savedContentCount = count) } } - .onFailure { _uiState.update { it.copy(savedContentCount = null) } } - } + contentRepository.getBookmarkedContentCount() + .onSuccess { count -> _uiState.update { it.copy(savedContentCount = count) } } + .onFailure { _uiState.update { it.copy(savedContentCount = null) } } } fun loadMoreBookmarkedContents() { @@ -380,12 +379,19 @@ class CollectionCreateViewModel @Inject constructor( _uiState.update { it.copy(isLoadingMore = true) } contentRepository.getBookmarkedContentList(cursor = cursor, size = BOOKMARKED_CONTENT_PAGE_SIZE) .onSuccess { model -> - _uiState.update { - it.copy( - contents = (it.contents + model.contents.map { c -> c.toSearchContentItemModel() }).toImmutableList(), - nextCursor = model.nextCursor, - isLoadingMore = false, - ) + _uiState.update { current -> + // 요청을 시작한 뒤 검색어가 바뀌었거나(북마크 목록을 더 이상 보고 있지 않음), + // nextCursor가 이미 다른 값으로 갈아치워진 경우(예: 검색어가 비어 loadBookmarkedContents가 + // 새로 실행됨) 이 응답은 오래된 것이므로 목록에 반영하지 않는다. + if (current.searchText.isNotBlank() || current.nextCursor != cursor) { + current.copy(isLoadingMore = false) + } else { + current.copy( + contents = (current.contents + model.contents.map { c -> c.toSearchContentItemModel() }).toImmutableList(), + nextCursor = model.nextCursor, + isLoadingMore = false, + ) + } } } .onFailure { From 3408cab7f0652476f1959d3ee34acabfc4d14ba4 Mon Sep 17 00:00:00 2001 From: chanmi1125 Date: Thu, 17 Sep 2026 14:24:22 +0900 Subject: [PATCH 6/6] =?UTF-8?q?[feat]=20=EC=BD=94=EB=A6=AC=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/collectioncreate/AddContentScreen.kt | 4 ++-- .../collectioncreate/CollectionCreateViewModel.kt | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/flint/android/presentation/collectioncreate/AddContentScreen.kt b/app/src/main/java/com/flint/android/presentation/collectioncreate/AddContentScreen.kt index 013d66df..b2129493 100644 --- a/app/src/main/java/com/flint/android/presentation/collectioncreate/AddContentScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/collectioncreate/AddContentScreen.kt @@ -111,8 +111,8 @@ fun AddContentScreen( derivedStateOf { val layoutInfo = lazyColumnState.layoutInfo val totalItems = layoutInfo.totalItemsCount - val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 - totalItems > 0 && lastVisibleIndex >= totalItems - 5 + val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull() ?: return@derivedStateOf false + totalItems > 5 && lastVisible.index >= totalItems - 5 } } diff --git a/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateViewModel.kt b/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateViewModel.kt index 6c77b383..cbfb6447 100644 --- a/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateViewModel.kt +++ b/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateViewModel.kt @@ -344,7 +344,7 @@ class CollectionCreateViewModel @Inject constructor( searchRepository.getSearchContentList(query) .onSuccess { model -> - _uiState.update { it.copy(contents = model.contents) } + _uiState.update { it.copy(contents = model.contents, nextCursor = null) } } .onFailure { _uiState.update { it.copy(contents = persistentListOf()) } @@ -386,8 +386,12 @@ class CollectionCreateViewModel @Inject constructor( if (current.searchText.isNotBlank() || current.nextCursor != cursor) { current.copy(isLoadingMore = false) } else { + val existingIds = current.contents.mapTo(mutableSetOf()) { it.id } + val newContents = model.contents + .map { c -> c.toSearchContentItemModel() } + .filterNot { it.id in existingIds } current.copy( - contents = (current.contents + model.contents.map { c -> c.toSearchContentItemModel() }).toImmutableList(), + contents = (current.contents + newContents).toImmutableList(), nextCursor = model.nextCursor, isLoadingMore = false, )