From dce0f54ddf07807f49a2f0f28a7e7ad9ed2f3b11 Mon Sep 17 00:00:00 2001 From: ckals413 Date: Tue, 19 May 2026 13:10:23 +0900 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20=EC=A0=80=EC=9E=A5=ED=95=9C=20?= =?UTF-8?q?=EC=9E=91=ED=92=88=20=EB=8D=94=EB=B3=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/flint/presentation/profile/ProfileScreen.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt b/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt index be81142f..5407a200 100644 --- a/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt +++ b/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt @@ -108,6 +108,7 @@ fun ProfileRoute( onContentItemClick = { contentId -> viewModel.getOttListPerContent(contentId) }, + onContentMoreClick = navigateToSavedContentList, onCreatedCollectionMoreClick = { navigateToCollectionList( CollectionListRouteType.CREATED, @@ -249,8 +250,8 @@ private fun ProfileScreen( description = "${userName}님이 저장한 작품이에요", contentModelList = sectionData.data.savedContents, onItemClick = onContentItemClick, - isAllVisible = false, - onAllClick = {}, + isAllVisible = true, + onAllClick = onContentMoreClick, ) } } From 6b3c5c7defca98f0178fc1d4aea85d95b380dfa6 Mon Sep 17 00:00:00 2001 From: ckals413 Date: Tue, 2 Jun 2026 20:28:24 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20=EC=A0=80=EC=9E=A5=20=EC=9E=91?= =?UTF-8?q?=ED=92=88=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=82=B4=20=EB=92=A4=EB=A1=9C=EA=B0=80=EA=B8=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/flint/presentation/main/MainNavHost.kt | 1 + .../savedcontent/SavedContentListScreen.kt | 15 +++++++++------ .../navigation/SavedContentNavigation.kt | 6 +++++- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/flint/presentation/main/MainNavHost.kt b/app/src/main/java/com/flint/presentation/main/MainNavHost.kt index 84a0dfb3..14a577ea 100644 --- a/app/src/main/java/com/flint/presentation/main/MainNavHost.kt +++ b/app/src/main/java/com/flint/presentation/main/MainNavHost.kt @@ -83,6 +83,7 @@ fun MainNavHost( savedContentListNavGraph( paddingValues = paddingValues, + navigateUp = navigator::navigateUp, ) exploreNavGraph( diff --git a/app/src/main/java/com/flint/presentation/savedcontent/SavedContentListScreen.kt b/app/src/main/java/com/flint/presentation/savedcontent/SavedContentListScreen.kt index a9c318da..54163664 100644 --- a/app/src/main/java/com/flint/presentation/savedcontent/SavedContentListScreen.kt +++ b/app/src/main/java/com/flint/presentation/savedcontent/SavedContentListScreen.kt @@ -2,12 +2,15 @@ package com.flint.presentation.savedcontent import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable +import com.flint.presentation.profile.SavedContentRoute @Composable -fun SavedContentListRoute(paddingValues: PaddingValues) { - SavedContentListScreen() -} - -@Composable -fun SavedContentListScreen() { +fun SavedContentListRoute( + paddingValues: PaddingValues, + navigateUp: () -> Unit, +) { + SavedContentRoute( + paddingValues = paddingValues, + navigateUp = navigateUp, + ) } diff --git a/app/src/main/java/com/flint/presentation/savedcontent/navigation/SavedContentNavigation.kt b/app/src/main/java/com/flint/presentation/savedcontent/navigation/SavedContentNavigation.kt index a41ba7aa..f0f5dfa0 100644 --- a/app/src/main/java/com/flint/presentation/savedcontent/navigation/SavedContentNavigation.kt +++ b/app/src/main/java/com/flint/presentation/savedcontent/navigation/SavedContentNavigation.kt @@ -12,10 +12,14 @@ fun NavController.navigateToSavedContentList(navOptions: NavOptions? = null) { navigate(Route.SavedContentList, navOptions) } -fun NavGraphBuilder.savedContentListNavGraph(paddingValues: PaddingValues) { +fun NavGraphBuilder.savedContentListNavGraph( + paddingValues: PaddingValues, + navigateUp: () -> Unit, +) { composable { SavedContentListRoute( paddingValues = paddingValues, + navigateUp = navigateUp, ) } } From 38a2aad547c887d54eb46abf491b7ebea6913da6 Mon Sep 17 00:00:00 2001 From: ckals413 Date: Tue, 2 Jun 2026 20:35:24 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20=EC=88=98=EC=A0=95=EB=90=9C=20api?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0=20(=EC=9E=91=ED=92=88=20=EC=B4=9D=20?= =?UTF-8?q?=EA=B0=9C=EC=88=98=20=EB=B0=8F=20=EB=B6=81=EB=A7=88=ED=81=AC=20?= =?UTF-8?q?=EC=88=98=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B0=98=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/content/response/BookmarkedContentListResponseDto.kt | 4 ++++ .../java/com/flint/domain/mapper/content/ContentMapper.kt | 2 ++ .../flint/domain/model/content/BookmarkedContentListModel.kt | 4 ++++ .../java/com/flint/presentation/profile/SavedContentScreen.kt | 2 +- .../flint/presentation/profile/uistate/SavedContentUiState.kt | 2 +- 5 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/flint/data/dto/content/response/BookmarkedContentListResponseDto.kt b/app/src/main/java/com/flint/data/dto/content/response/BookmarkedContentListResponseDto.kt index 9066b570..311c9c5c 100644 --- a/app/src/main/java/com/flint/data/dto/content/response/BookmarkedContentListResponseDto.kt +++ b/app/src/main/java/com/flint/data/dto/content/response/BookmarkedContentListResponseDto.kt @@ -5,6 +5,8 @@ import kotlinx.serialization.Serializable @Serializable data class BookmarkedContentListResponseDto( + @SerialName("totalCount") + val totalCount: Int, @SerialName("contents") val contents: List ) @@ -19,6 +21,8 @@ data class BookmarkedContentResponseDto( val year: Int, @SerialName("imageUrl") val imageUrl: String, + @SerialName("bookmarkCount") + val bookmarkCount: Int, @SerialName("getOttSimpleList") val getOttSimpleList: List ) diff --git a/app/src/main/java/com/flint/domain/mapper/content/ContentMapper.kt b/app/src/main/java/com/flint/domain/mapper/content/ContentMapper.kt index 3ac5ec80..942b2ff1 100644 --- a/app/src/main/java/com/flint/domain/mapper/content/ContentMapper.kt +++ b/app/src/main/java/com/flint/domain/mapper/content/ContentMapper.kt @@ -12,6 +12,7 @@ import kotlinx.collections.immutable.toImmutableList fun BookmarkedContentListResponseDto.toModel() : BookmarkedContentListModel { return BookmarkedContentListModel( + totalCount = totalCount, contents = contents.map { it.toModel() }.toImmutableList() ) } @@ -22,6 +23,7 @@ fun BookmarkedContentResponseDto.toModel() : BookmarkedContentItemModel { title = title, year = year, imageUrl = imageUrl, + bookmarkCount = bookmarkCount, getOttSimpleList = getOttSimpleList.mapNotNull { ottSimple -> runCatching { OttType.valueOf(ottSimple.ottName) }.getOrNull() } diff --git a/app/src/main/java/com/flint/domain/model/content/BookmarkedContentListModel.kt b/app/src/main/java/com/flint/domain/model/content/BookmarkedContentListModel.kt index d878a57f..fddd06a3 100644 --- a/app/src/main/java/com/flint/domain/model/content/BookmarkedContentListModel.kt +++ b/app/src/main/java/com/flint/domain/model/content/BookmarkedContentListModel.kt @@ -5,16 +5,19 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf data class BookmarkedContentListModel( + val totalCount: Int = 0, val contents: ImmutableList = persistentListOf() ) { companion object { val FakeList = BookmarkedContentListModel( + totalCount = 1, contents = persistentListOf( BookmarkedContentItemModel( id = "0", title = "드라마 제목", year = 2000, imageUrl = "", + bookmarkCount = 0, getOttSimpleList = listOf( OttType.Netflix, OttType.Disney, @@ -32,5 +35,6 @@ data class BookmarkedContentItemModel( val title: String = "", val year: Int = 0, val imageUrl: String = "", + val bookmarkCount: Int = 0, val getOttSimpleList: List = emptyList() ) \ No newline at end of file diff --git a/app/src/main/java/com/flint/presentation/profile/SavedContentScreen.kt b/app/src/main/java/com/flint/presentation/profile/SavedContentScreen.kt index 9a10de52..28bac4a2 100644 --- a/app/src/main/java/com/flint/presentation/profile/SavedContentScreen.kt +++ b/app/src/main/java/com/flint/presentation/profile/SavedContentScreen.kt @@ -193,7 +193,7 @@ private fun SavedContentList( onBookmarkClick = { onBookmarkClick(content.id) }, onMoreClick = {}, isBookmarked = true, - bookmarkCount = 123, + bookmarkCount = content.bookmarkCount, imageUrl = content.imageUrl, title = content.title, director = "감독이름", diff --git a/app/src/main/java/com/flint/presentation/profile/uistate/SavedContentUiState.kt b/app/src/main/java/com/flint/presentation/profile/uistate/SavedContentUiState.kt index 03c435a7..5ede9689 100644 --- a/app/src/main/java/com/flint/presentation/profile/uistate/SavedContentUiState.kt +++ b/app/src/main/java/com/flint/presentation/profile/uistate/SavedContentUiState.kt @@ -33,7 +33,7 @@ data class SavedContentUiState( * 화면 상단 "총 n개"에 사용할 카운트 (전체 저장 작품 수). */ val totalCount: Int - get() = (contents as? UiState.Success)?.data?.contents?.size ?: 0 + get() = (contents as? UiState.Success)?.data?.totalCount ?: 0 companion object { /** From b773159a35394d607dcab6a242dea15a6e1c30c6 Mon Sep 17 00:00:00 2001 From: ckals413 Date: Thu, 4 Jun 2026 23:25:38 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20=EC=A0=80=EC=9E=A5=EB=90=9C=20?= =?UTF-8?q?=EC=9E=91=ED=92=88=20=EB=B6=81=EB=A7=88=ED=81=AC=20=ED=95=B4?= =?UTF-8?q?=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EB=B0=8F?= =?UTF-8?q?=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../profile/SavedContentViewModel.kt | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt b/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt index 52813265..f860c5c1 100644 --- a/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt +++ b/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt @@ -3,6 +3,7 @@ package com.flint.presentation.profile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flint.core.common.util.UiState +import com.flint.domain.repository.BookmarkRepository import com.flint.domain.repository.ContentRepository import com.flint.presentation.profile.uistate.SavedContentUiState import com.flint.presentation.profile.uistate.SavedContentUiState.Companion.MIN_REQUIRED_COUNT @@ -18,6 +19,7 @@ import javax.inject.Inject @HiltViewModel class SavedContentViewModel @Inject constructor( private val contentRepository: ContentRepository, + private val bookmarkRepository: BookmarkRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(SavedContentUiState()) @@ -67,12 +69,24 @@ class SavedContentViewModel @Inject constructor( // 저장된 작품이 MIN_REQUIRED_COUNT(5)개일 때는 취소를 막고 안내 모달을 노출한다. fun toggleBookmark(contentId: String) { val currentCount = uiState.value.totalCount + Timber.d("toggleBookmark called: contentId=$contentId, currentCount=$currentCount") + if (currentCount <= MIN_REQUIRED_COUNT) { + Timber.d("toggleBookmark blocked: count($currentCount) <= MIN($MIN_REQUIRED_COUNT)") _uiState.update { it.copy(showBookmarkRestrictionModal = true) } return } - // TODO: 북마크 토글 API 연동 - Timber.d("toggleBookmark: $contentId") + viewModelScope.launch { + Timber.d("toggleBookmark: calling API for contentId=$contentId") + bookmarkRepository.toggleContentBookmark(contentId) + .onSuccess { isBookmarked -> + Timber.d("toggleBookmark success: isBookmarked=$isBookmarked") + loadBookmarkedContents() + } + .onFailure { throwable -> + Timber.e(throwable, "toggleBookmark failed: contentId=$contentId") + } + } } // 저장 취소 제한 안내 모달 닫기 From 090484b66f05e40253348ccf49cffc787368e283 Mon Sep 17 00:00:00 2001 From: ckals413 Date: Fri, 5 Jun 2026 00:43:15 +0900 Subject: [PATCH 05/14] =?UTF-8?q?feat:=20=EC=A0=80=EC=9E=A5=20=EC=9E=91?= =?UTF-8?q?=ED=92=88=20=EB=AA=A9=EB=A1=9D=20=EB=B6=81=EB=A7=88=ED=81=AC=20?= =?UTF-8?q?=ED=86=A0=EA=B8=80=20=EC=8B=9C=20=EB=A1=9C=EB=94=A9=20=EC=9D=B8?= =?UTF-8?q?=EB=94=94=EC=BC=80=EC=9D=B4=ED=84=B0=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/flint/presentation/profile/SavedContentScreen.kt | 1 + .../flint/presentation/profile/SavedContentViewModel.kt | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/flint/presentation/profile/SavedContentScreen.kt b/app/src/main/java/com/flint/presentation/profile/SavedContentScreen.kt index 28bac4a2..2acaf670 100644 --- a/app/src/main/java/com/flint/presentation/profile/SavedContentScreen.kt +++ b/app/src/main/java/com/flint/presentation/profile/SavedContentScreen.kt @@ -190,6 +190,7 @@ private fun SavedContentList( key = { it.id }, ) { content -> CollectionCreateContentBookmark( + modifier = Modifier.animateItem(), onBookmarkClick = { onBookmarkClick(content.id) }, onMoreClick = {}, isBookmarked = true, diff --git a/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt b/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt index f860c5c1..1e90fead 100644 --- a/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt +++ b/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt @@ -31,9 +31,11 @@ class SavedContentViewModel @Inject constructor( // 사용자 저장한 작품 목록 호출 - fun loadBookmarkedContents() { + fun loadBookmarkedContents(showLoading: Boolean = true) { viewModelScope.launch { - _uiState.update { it.copy(contents = UiState.Loading) } + if (showLoading) { + _uiState.update { it.copy(contents = UiState.Loading) } + } contentRepository.getBookmarkedContentList() .onSuccess { list -> @@ -81,7 +83,7 @@ class SavedContentViewModel @Inject constructor( bookmarkRepository.toggleContentBookmark(contentId) .onSuccess { isBookmarked -> Timber.d("toggleBookmark success: isBookmarked=$isBookmarked") - loadBookmarkedContents() + loadBookmarkedContents(showLoading = false) } .onFailure { throwable -> Timber.e(throwable, "toggleBookmark failed: contentId=$contentId") From c841cf10845c67c261b347871600c8e7ac771e36 Mon Sep 17 00:00:00 2001 From: ckals413 Date: Fri, 5 Jun 2026 20:28:44 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20=EC=A0=80=EC=9E=A5=EB=90=9C=20?= =?UTF-8?q?=EC=9E=91=ED=92=88=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20ID=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EB=B0=8F=20=ED=83=80=EC=9D=B8=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/flint/core/navigation/Route.kt | 4 +++- .../java/com/flint/presentation/main/MainNavHost.kt | 4 ++-- .../com/flint/presentation/main/MainNavigator.kt | 4 ++-- .../com/flint/presentation/profile/ProfileScreen.kt | 4 ++-- .../presentation/profile/SavedContentViewModel.kt | 13 +++++++++++-- .../profile/navigation/ProfileNavigation.kt | 4 ++-- .../navigation/SavedContentNavigation.kt | 4 ++-- 7 files changed, 24 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/flint/core/navigation/Route.kt b/app/src/main/java/com/flint/core/navigation/Route.kt index 48a191ff..1dba9667 100644 --- a/app/src/main/java/com/flint/core/navigation/Route.kt +++ b/app/src/main/java/com/flint/core/navigation/Route.kt @@ -49,7 +49,9 @@ interface Route { data object CollectionCreateGraph : Route @Serializable - data object SavedContentList : Route + data class SavedContentList( + val userId: String? = null, + ) : Route @Serializable data object AddContent : Route diff --git a/app/src/main/java/com/flint/presentation/main/MainNavHost.kt b/app/src/main/java/com/flint/presentation/main/MainNavHost.kt index 14a577ea..a12c352d 100644 --- a/app/src/main/java/com/flint/presentation/main/MainNavHost.kt +++ b/app/src/main/java/com/flint/presentation/main/MainNavHost.kt @@ -95,7 +95,7 @@ fun MainNavHost( myProfileNavGraph( paddingValues = paddingValues, navigateToCollectionList = navigator::navigateToCollectionList, - navigateToSavedContentList = navigator::navigateToSavedContent, + navigateToSavedContentList = { userId -> navigator.navigateToSavedContent(userId) }, navigateToCollectionDetail = navigator::navigateToCollectionDetail, ) @@ -103,7 +103,7 @@ fun MainNavHost( paddingValues = paddingValues, navigateUp = navigator::navigateUp, navigateToCollectionList = navigator::navigateToCollectionList, - navigateToSavedContentList = navigator::navigateToSavedContent, + navigateToSavedContentList = { userId -> navigator.navigateToSavedContent(userId) }, navigateToCollectionDetail = navigator::navigateToCollectionDetail, ) } diff --git a/app/src/main/java/com/flint/presentation/main/MainNavigator.kt b/app/src/main/java/com/flint/presentation/main/MainNavigator.kt index 57d6cf88..cbf0bd67 100644 --- a/app/src/main/java/com/flint/presentation/main/MainNavigator.kt +++ b/app/src/main/java/com/flint/presentation/main/MainNavigator.kt @@ -147,8 +147,8 @@ class MainNavigator( navController.navigateToCollectionCreate(navOptions) } - fun navigateToSavedContent(navOptions: NavOptions? = null) { - navController.navigateToSavedContentList(navOptions) + fun navigateToSavedContent(userId: String? = null, navOptions: NavOptions? = null) { + navController.navigateToSavedContentList(userId = userId, navOptions = navOptions) } fun navigateToProfile(userId: String, navOptions: NavOptions? = null) { diff --git a/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt b/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt index 5407a200..61f72244 100644 --- a/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt +++ b/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt @@ -63,7 +63,7 @@ fun ProfileRoute( paddingValues: PaddingValues, navigateUp: () -> Unit, navigateToCollectionList: (routeType: CollectionListRouteType, userId: String?) -> Unit, - navigateToSavedContentList: () -> Unit, // TODO: 스프린트에서 구현 + navigateToSavedContentList: (userId: String?) -> Unit, navigateToCollectionDetail: (collectionId: String) -> Unit, viewModel: ProfileViewModel = hiltViewModel(), ) { @@ -108,7 +108,7 @@ fun ProfileRoute( onContentItemClick = { contentId -> viewModel.getOttListPerContent(contentId) }, - onContentMoreClick = navigateToSavedContentList, + onContentMoreClick = { navigateToSavedContentList(uiState.userId) }, onCreatedCollectionMoreClick = { navigateToCollectionList( CollectionListRouteType.CREATED, diff --git a/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt b/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt index 1e90fead..029553de 100644 --- a/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt +++ b/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt @@ -1,10 +1,14 @@ package com.flint.presentation.profile +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute import com.flint.core.common.util.UiState +import com.flint.core.navigation.Route import com.flint.domain.repository.BookmarkRepository import com.flint.domain.repository.ContentRepository +import com.flint.domain.repository.UserRepository import com.flint.presentation.profile.uistate.SavedContentUiState import com.flint.presentation.profile.uistate.SavedContentUiState.Companion.MIN_REQUIRED_COUNT import dagger.hilt.android.lifecycle.HiltViewModel @@ -19,9 +23,13 @@ import javax.inject.Inject @HiltViewModel class SavedContentViewModel @Inject constructor( private val contentRepository: ContentRepository, + private val userRepository: UserRepository, private val bookmarkRepository: BookmarkRepository, + savedStateHandle: SavedStateHandle, ) : ViewModel() { + private val userId: String? = savedStateHandle.toRoute().userId + private val _uiState = MutableStateFlow(SavedContentUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -37,7 +45,7 @@ class SavedContentViewModel @Inject constructor( _uiState.update { it.copy(contents = UiState.Loading) } } - contentRepository.getBookmarkedContentList() + userRepository.getUserBookmarkedContents(userId) .onSuccess { list -> _uiState.update { it.copy( @@ -67,9 +75,10 @@ class SavedContentViewModel @Inject constructor( } - // 북마크 토글 (저장 취소) + // 북마크 토글 (저장 취소) — 내 프로필에서만 동작 // 저장된 작품이 MIN_REQUIRED_COUNT(5)개일 때는 취소를 막고 안내 모달을 노출한다. fun toggleBookmark(contentId: String) { + if (userId != null) return // 타유저 프로필에서는 북마크 토글 불가 val currentCount = uiState.value.totalCount Timber.d("toggleBookmark called: contentId=$contentId, currentCount=$currentCount") diff --git a/app/src/main/java/com/flint/presentation/profile/navigation/ProfileNavigation.kt b/app/src/main/java/com/flint/presentation/profile/navigation/ProfileNavigation.kt index de8a3fa6..f7ff0b57 100644 --- a/app/src/main/java/com/flint/presentation/profile/navigation/ProfileNavigation.kt +++ b/app/src/main/java/com/flint/presentation/profile/navigation/ProfileNavigation.kt @@ -26,7 +26,7 @@ fun NavController.navigateToProfile( fun NavGraphBuilder.myProfileNavGraph( paddingValues: PaddingValues, navigateToCollectionList: (routeType: CollectionListRouteType, userId: String?) -> Unit, - navigateToSavedContentList: () -> Unit, + navigateToSavedContentList: (userId: String?) -> Unit, navigateToCollectionDetail: (collectionId: String) -> Unit, ) { composable { @@ -44,7 +44,7 @@ fun NavGraphBuilder.profileNavGraph( paddingValues: PaddingValues, navigateUp: () -> Unit, navigateToCollectionList: (routeType: CollectionListRouteType, userId: String?) -> Unit, - navigateToSavedContentList: () -> Unit, + navigateToSavedContentList: (userId: String?) -> Unit, navigateToCollectionDetail: (collectionId: String) -> Unit, ) { composable { diff --git a/app/src/main/java/com/flint/presentation/savedcontent/navigation/SavedContentNavigation.kt b/app/src/main/java/com/flint/presentation/savedcontent/navigation/SavedContentNavigation.kt index f0f5dfa0..7780808a 100644 --- a/app/src/main/java/com/flint/presentation/savedcontent/navigation/SavedContentNavigation.kt +++ b/app/src/main/java/com/flint/presentation/savedcontent/navigation/SavedContentNavigation.kt @@ -8,8 +8,8 @@ import androidx.navigation.compose.composable import com.flint.core.navigation.Route import com.flint.presentation.savedcontent.SavedContentListRoute -fun NavController.navigateToSavedContentList(navOptions: NavOptions? = null) { - navigate(Route.SavedContentList, navOptions) +fun NavController.navigateToSavedContentList(userId: String? = null, navOptions: NavOptions? = null) { + navigate(Route.SavedContentList(userId = userId), navOptions) } fun NavGraphBuilder.savedContentListNavGraph( From 1df7194088458051fb6d5ec72fb3ceb1fe1c1d2a Mon Sep 17 00:00:00 2001 From: ckals413 Date: Sat, 6 Jun 2026 15:27:31 +0900 Subject: [PATCH 07/14] =?UTF-8?q?feat:=20=EB=B6=81=EB=A7=88=ED=81=AC=20?= =?UTF-8?q?=EC=B5=9C=EC=86=8C=20=EA=B0=9C=EC=88=98=20=EC=A0=9C=ED=95=9C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=9D=84=20=EC=84=9C=EB=B2=84=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=9D=91=EB=8B=B5=20=EC=B2=98=EB=A6=AC=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../di/interceptor/NetworkErrorInterceptor.kt | 18 ++++++++---- .../profile/SavedContentViewModel.kt | 29 ++++++++++++------- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/flint/data/di/interceptor/NetworkErrorInterceptor.kt b/app/src/main/java/com/flint/data/di/interceptor/NetworkErrorInterceptor.kt index 796f1774..7b9ae6cd 100644 --- a/app/src/main/java/com/flint/data/di/interceptor/NetworkErrorInterceptor.kt +++ b/app/src/main/java/com/flint/data/di/interceptor/NetworkErrorInterceptor.kt @@ -25,13 +25,19 @@ class NetworkErrorInterceptor @Inject constructor( if (!response.isSuccessful) { when (response.code) { in 300..599 -> { - scope.launch { - networkErrorManager.emitError( - NetworkError.ConnectionError( - code = response.code, - message = response.message + // errorCode 필드가 있으면 앱 레이어에서 직접 처리하는 비즈니스 에러 — 글로벌 에러 emit 생략 + val isBusinessError = response.peekBody(Long.MAX_VALUE) + .string() + .contains("\"errorCode\"") + if (!isBusinessError) { + scope.launch { + networkErrorManager.emitError( + NetworkError.ConnectionError( + code = response.code, + message = response.message + ) ) - ) + } } } } diff --git a/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt b/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt index 029553de..d445e112 100644 --- a/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt +++ b/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt @@ -10,13 +10,16 @@ import com.flint.domain.repository.BookmarkRepository import com.flint.domain.repository.ContentRepository import com.flint.domain.repository.UserRepository import com.flint.presentation.profile.uistate.SavedContentUiState -import com.flint.presentation.profile.uistate.SavedContentUiState.Companion.MIN_REQUIRED_COUNT import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import retrofit2.HttpException import timber.log.Timber import javax.inject.Inject @@ -79,14 +82,6 @@ class SavedContentViewModel @Inject constructor( // 저장된 작품이 MIN_REQUIRED_COUNT(5)개일 때는 취소를 막고 안내 모달을 노출한다. fun toggleBookmark(contentId: String) { if (userId != null) return // 타유저 프로필에서는 북마크 토글 불가 - val currentCount = uiState.value.totalCount - Timber.d("toggleBookmark called: contentId=$contentId, currentCount=$currentCount") - - if (currentCount <= MIN_REQUIRED_COUNT) { - Timber.d("toggleBookmark blocked: count($currentCount) <= MIN($MIN_REQUIRED_COUNT)") - _uiState.update { it.copy(showBookmarkRestrictionModal = true) } - return - } viewModelScope.launch { Timber.d("toggleBookmark: calling API for contentId=$contentId") bookmarkRepository.toggleContentBookmark(contentId) @@ -95,7 +90,21 @@ class SavedContentViewModel @Inject constructor( loadBookmarkedContents(showLoading = false) } .onFailure { throwable -> - Timber.e(throwable, "toggleBookmark failed: contentId=$contentId") + val isMinLimitError = (throwable as? HttpException) + ?.response()?.errorBody()?.string() + ?.let { body -> + runCatching { + Json.parseToJsonElement(body) + .jsonObject["errorCode"] + ?.jsonPrimitive?.content == "BOOKMARK.CONTENT_MIN_LIMIT" + }.getOrDefault(false) + } ?: false + + if (isMinLimitError) { + _uiState.update { it.copy(showBookmarkRestrictionModal = true) } + } else { + Timber.e(throwable, "toggleBookmark failed: contentId=$contentId") + } } } } From 4c383a106f2d0dac760660b9fc88c868b0c174f4 Mon Sep 17 00:00:00 2001 From: ckals413 Date: Sat, 6 Jun 2026 15:55:50 +0900 Subject: [PATCH 08/14] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=9E=AC=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=20=EC=97=AC=EB=B6=80=20=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=EB=B0=8F=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=ED=99=9C=EC=84=B1=20=EC=83=81=ED=83=9C=20=EC=A0=9C?= =?UTF-8?q?=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/flint/data/api/UserApi.kt | 4 ++++ .../user/response/UserProfileResponseDto.kt | 4 +++- .../flint/domain/mapper/user/ProfileMapper.kt | 3 ++- .../model/user/UserProfileResponseModel.kt | 3 ++- .../flint/domain/repository/UserRepository.kt | 8 ++++++-- .../presentation/profile/ProfileScreen.kt | 1 + .../profile/component/ProfileKeywordSection.kt | 18 ++++++++++++------ 7 files changed, 30 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/flint/data/api/UserApi.kt b/app/src/main/java/com/flint/data/api/UserApi.kt index 37ff8993..5d51dd49 100644 --- a/app/src/main/java/com/flint/data/api/UserApi.kt +++ b/app/src/main/java/com/flint/data/api/UserApi.kt @@ -12,6 +12,10 @@ import retrofit2.http.Path import retrofit2.http.Query interface UserApi { + // 내 프로필 조회 (keywordRecalculatable 포함) + @GET("/api/v1/users/me") + suspend fun getMyProfile(): BaseResponse + // 사용자 프로필 조회 @GET("/api/v1/users/{userId}") suspend fun getUserProfile( diff --git a/app/src/main/java/com/flint/data/dto/user/response/UserProfileResponseDto.kt b/app/src/main/java/com/flint/data/dto/user/response/UserProfileResponseDto.kt index 9fc57855..04090b63 100644 --- a/app/src/main/java/com/flint/data/dto/user/response/UserProfileResponseDto.kt +++ b/app/src/main/java/com/flint/data/dto/user/response/UserProfileResponseDto.kt @@ -12,5 +12,7 @@ data class UserProfileResponseDto( @SerializedName("isFliner") val isFliner: Boolean, @SerializedName("nickname") - val nickname: String + val nickname: String, + @SerializedName("keywordRecalculatable") + val keywordRecalculatable: Boolean = false, ) \ No newline at end of file diff --git a/app/src/main/java/com/flint/domain/mapper/user/ProfileMapper.kt b/app/src/main/java/com/flint/domain/mapper/user/ProfileMapper.kt index 0f59af23..257df653 100644 --- a/app/src/main/java/com/flint/domain/mapper/user/ProfileMapper.kt +++ b/app/src/main/java/com/flint/domain/mapper/user/ProfileMapper.kt @@ -8,5 +8,6 @@ fun UserProfileResponseDto.toModel(): UserProfileResponseModel = id = id, isFliner = isFliner, nickname = nickname, - profileImageUrl = profileImageUrl + profileImageUrl = profileImageUrl, + keywordRecalculatable = keywordRecalculatable, ) \ No newline at end of file diff --git a/app/src/main/java/com/flint/domain/model/user/UserProfileResponseModel.kt b/app/src/main/java/com/flint/domain/model/user/UserProfileResponseModel.kt index 34493e7a..f20eef88 100644 --- a/app/src/main/java/com/flint/domain/model/user/UserProfileResponseModel.kt +++ b/app/src/main/java/com/flint/domain/model/user/UserProfileResponseModel.kt @@ -4,7 +4,8 @@ data class UserProfileResponseModel( val id: String, val isFliner: Boolean, val nickname: String, - val profileImageUrl: String? + val profileImageUrl: String?, + val keywordRecalculatable: Boolean = false, ) { companion object { val Empty = UserProfileResponseModel( diff --git a/app/src/main/java/com/flint/domain/repository/UserRepository.kt b/app/src/main/java/com/flint/domain/repository/UserRepository.kt index d2883dfa..7266bb11 100644 --- a/app/src/main/java/com/flint/domain/repository/UserRepository.kt +++ b/app/src/main/java/com/flint/domain/repository/UserRepository.kt @@ -23,10 +23,14 @@ class UserRepository @Inject constructor( return preferencesManager.getString(USER_ID).first() } - // 사용자 프로필 조회 + // 사용자 프로필 조회 (내 프로필, 타 유저) suspend fun getUserProfile(userId: String?): Result = suspendRunCatching { - apiService.getUserProfile(userId ?: myUserId()).data.toModel() + if (userId == null) { + apiService.getMyProfile().data.toModel() + } else { + apiService.getUserProfile(userId).data.toModel() + } } // 사용자 취향 키워드 조회 diff --git a/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt b/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt index 61f72244..54857f21 100644 --- a/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt +++ b/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt @@ -205,6 +205,7 @@ private fun ProfileScreen( nickname = uiState.profile.nickname, keywordList = sectionData.data.keywords, isMyProfile = uiState.userId == null, + isRecalculatable = uiState.profile.keywordRecalculatable, showInfoModal = showInfoModal, onInfoClick = { showInfoModal = !showInfoModal }, onRefreshClick = onRefreshClick, diff --git a/app/src/main/java/com/flint/presentation/profile/component/ProfileKeywordSection.kt b/app/src/main/java/com/flint/presentation/profile/component/ProfileKeywordSection.kt index 9090f308..160d31b0 100644 --- a/app/src/main/java/com/flint/presentation/profile/component/ProfileKeywordSection.kt +++ b/app/src/main/java/com/flint/presentation/profile/component/ProfileKeywordSection.kt @@ -42,6 +42,7 @@ fun ProfileKeywordSection( nickname: String, keywordList: KeywordListModel, isMyProfile: Boolean, + isRecalculatable: Boolean, showInfoModal: Boolean, onInfoClick: () -> Unit, onRefreshClick: () -> Unit, @@ -87,7 +88,10 @@ fun ProfileKeywordSection( ) } if (isMyProfile) { - ProfileRefreshButton(onRefreshClick = onRefreshClick) + ProfileRefreshButton( + onRefreshClick = onRefreshClick, + isEnabled = isRecalculatable, + ) } } Spacer(Modifier.height(32.dp)) @@ -117,21 +121,22 @@ fun ProfileKeywordSection( @Composable private fun ProfileRefreshButton( onRefreshClick: () -> Unit, + isEnabled: Boolean, modifier: Modifier = Modifier, ) { + val iconTint = if (isEnabled) FlintTheme.colors.secondary400 else FlintTheme.colors.gray400 Column( verticalArrangement = Arrangement.spacedBy(4.dp), horizontalAlignment = Alignment.CenterHorizontally, modifier = - modifier - .noRippleClickable( - onClick = onRefreshClick, - ), + modifier.noRippleClickable( + onClick = { if (isEnabled) onRefreshClick() }, + ), ) { Icon( imageVector = ImageVector.vectorResource(R.drawable.ic_refresh), contentDescription = null, - tint = FlintTheme.colors.secondary400, + tint = iconTint, ) Text( text = "업데이트", @@ -237,6 +242,7 @@ private fun ProfileKeywordSectionPreview() { keywordList = KeywordListModel.FakeList3, modifier = Modifier.fillMaxSize(), isMyProfile = true, + isRecalculatable = true, showInfoModal = false, onInfoClick = {}, onRefreshClick = {}, From b4f0061d164afee912e1015f83ceebdd1171e740 Mon Sep 17 00:00:00 2001 From: ckals413 Date: Sat, 6 Jun 2026 16:11:16 +0900 Subject: [PATCH 09/14] =?UTF-8?q?feat:=20=EC=B7=A8=ED=96=A5=20=ED=82=A4?= =?UTF-8?q?=EC=9B=8C=EB=93=9C=20=EC=9E=AC=EA=B3=84=EC=82=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/java/com/flint/data/api/UserApi.kt | 6 +++++- .../flint/domain/repository/UserRepository.kt | 4 ++++ .../flint/presentation/profile/ProfileScreen.kt | 1 + .../presentation/profile/ProfileViewModel.kt | 17 +++++++++++++++++ 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/flint/data/api/UserApi.kt b/app/src/main/java/com/flint/data/api/UserApi.kt index 5d51dd49..eebe1920 100644 --- a/app/src/main/java/com/flint/data/api/UserApi.kt +++ b/app/src/main/java/com/flint/data/api/UserApi.kt @@ -7,7 +7,9 @@ import com.flint.data.dto.user.response.BookmarkedCollectionListResponseDto import com.flint.data.dto.user.response.CreatedCollectionListResponseDto import com.flint.data.dto.user.response.UserKeywordsResponseDto import com.flint.data.dto.user.response.UserProfileResponseDto +import retrofit2.Response import retrofit2.http.GET +import retrofit2.http.PATCH import retrofit2.http.Path import retrofit2.http.Query @@ -60,5 +62,7 @@ interface UserApi { @Path("userId") userId: String, ): BaseResponse - // 취향 키워드 재계산 + // 취향 키워드 재계산 (응답 body에 data 필드xx) + @PATCH("/api/v1/users/me/keywords/recalculate") + suspend fun recalculateKeywords(): Response } diff --git a/app/src/main/java/com/flint/domain/repository/UserRepository.kt b/app/src/main/java/com/flint/domain/repository/UserRepository.kt index 7266bb11..83a71e88 100644 --- a/app/src/main/java/com/flint/domain/repository/UserRepository.kt +++ b/app/src/main/java/com/flint/domain/repository/UserRepository.kt @@ -63,6 +63,10 @@ class UserRepository @Inject constructor( suspend fun getUserBookmarkedContents(userId: String?) : Result = suspendRunCatching { apiService.getBookmarkedContentListByUserId(userId ?: myUserId()).data.toModel() } + // 취향 키워드 재계산 + suspend fun recalculateKeywords(): Result = + suspendRunCatching { apiService.recalculateKeywords().also { check(it.isSuccessful) } } + // 닉네임 중복 체크 suspend fun checkNickname(nickname: String): Result = suspendRunCatching { diff --git a/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt b/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt index 54857f21..2bd05c8d 100644 --- a/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt +++ b/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt @@ -121,6 +121,7 @@ fun ProfileRoute( uiState.userId ) }, + onRefreshClick = viewModel::recalculateKeywords, onEasterEggWithdraw = viewModel::easterEggWithdraw, ) diff --git a/app/src/main/java/com/flint/presentation/profile/ProfileViewModel.kt b/app/src/main/java/com/flint/presentation/profile/ProfileViewModel.kt index 04603045..c1ca582f 100644 --- a/app/src/main/java/com/flint/presentation/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/flint/presentation/profile/ProfileViewModel.kt @@ -98,6 +98,23 @@ class ProfileViewModel @Inject constructor( } } + fun recalculateKeywords() = viewModelScope.launch { + userRepository.recalculateKeywords() + .onSuccess { + // 버튼 즉시 비활성화 후 키워드 재조회 + _uiState.update { it.copy(profile = it.profile.copy(keywordRecalculatable = false)) } + userRepository.getUserKeywords(userId = null) + .onSuccess { keywords -> + _uiState.update { state -> + val current = (state.sectionData as? UiState.Success)?.data ?: return@update state + state.copy(sectionData = UiState.Success(current.copy(keywords = keywords))) + } + } + .onFailure { Timber.e(it) } + } + .onFailure { Timber.e(it) } + } + fun easterEggWithdraw() = viewModelScope.launch { authRepository.withdraw() .onSuccess { From 0ed097c94b675330fd3a32f717d1b5e23858250e Mon Sep 17 00:00:00 2001 From: ckals413 Date: Wed, 10 Jun 2026 03:01:46 +0900 Subject: [PATCH 10/14] =?UTF-8?q?feat:=20=EB=B6=81=EB=A7=88=ED=81=AC=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=83=81=ED=83=9C=20=EC=8B=A4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EB=B0=98=EC=98=81=20=EB=B0=8F=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20UI=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/model/bookmark/BookmarkChange.kt | 16 ++++++ .../domain/repository/BookmarkRepository.kt | 18 +++++++ .../presentation/profile/ProfileScreen.kt | 4 -- .../presentation/profile/ProfileViewModel.kt | 49 ++++++++++++++++++- .../profile/SavedContentViewModel.kt | 19 ++++++- 5 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/flint/domain/model/bookmark/BookmarkChange.kt diff --git a/app/src/main/java/com/flint/domain/model/bookmark/BookmarkChange.kt b/app/src/main/java/com/flint/domain/model/bookmark/BookmarkChange.kt new file mode 100644 index 00000000..41db530f --- /dev/null +++ b/app/src/main/java/com/flint/domain/model/bookmark/BookmarkChange.kt @@ -0,0 +1,16 @@ +package com.flint.domain.model.bookmark + +sealed class BookmarkChange { + abstract val id: String + abstract val isBookmarked: Boolean + + data class Content( + override val id: String, + override val isBookmarked: Boolean, + ) : BookmarkChange() + + data class Collection( + override val id: String, + override val isBookmarked: Boolean, + ) : BookmarkChange() +} diff --git a/app/src/main/java/com/flint/domain/repository/BookmarkRepository.kt b/app/src/main/java/com/flint/domain/repository/BookmarkRepository.kt index aa482de7..3a175a83 100644 --- a/app/src/main/java/com/flint/domain/repository/BookmarkRepository.kt +++ b/app/src/main/java/com/flint/domain/repository/BookmarkRepository.kt @@ -3,12 +3,20 @@ package com.flint.domain.repository import com.flint.core.common.util.suspendRunCatching import com.flint.data.api.BookmarkApi import com.flint.domain.mapper.bookmark.toModel +import com.flint.domain.model.bookmark.BookmarkChange import com.flint.domain.model.bookmark.CollectionBookmarkUsersModel import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +@Singleton class BookmarkRepository @Inject constructor( private val api: BookmarkApi, ) { + private val _bookmarkChanges = MutableSharedFlow() + val bookmarkChanges = _bookmarkChanges.asSharedFlow() + // 컬렉션 북마크 유저 조회 suspend fun getCollectionBookmarkUsers(collectionId: String): Result { return suspendRunCatching { api.getCollectionBookmarkUsers(collectionId).data.toModel() } @@ -17,8 +25,18 @@ class BookmarkRepository @Inject constructor( // 컬렉션 북마크 토글 suspend fun toggleCollectionBookmark(collectionId: String): Result = suspendRunCatching { api.toggleCollectionBookmark(collectionId).data } + .also { result -> + result.onSuccess { isBookmarked -> + _bookmarkChanges.emit(BookmarkChange.Collection(collectionId, isBookmarked)) + } + } // 콘텐츠 북마크 토글 suspend fun toggleContentBookmark(contentId: String): Result = suspendRunCatching { api.toggleContentBookmark(contentId).data } + .also { result -> + result.onSuccess { isBookmarked -> + _bookmarkChanges.emit(BookmarkChange.Content(contentId, isBookmarked)) + } + } } diff --git a/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt b/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt index 2bd05c8d..17460e97 100644 --- a/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt +++ b/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt @@ -75,10 +75,6 @@ fun ProfileRoute( var ottListModel by remember { mutableStateOf(OttListModel()) } val sheetState = rememberModalBottomSheetState() - LaunchedEffect(Unit) { - viewModel.getProfile() - } - LaunchedEffect(Unit) { viewModel.sideEffect.collect { sideEffect -> when (sideEffect) { diff --git a/app/src/main/java/com/flint/presentation/profile/ProfileViewModel.kt b/app/src/main/java/com/flint/presentation/profile/ProfileViewModel.kt index c1ca582f..1af92ccf 100644 --- a/app/src/main/java/com/flint/presentation/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/flint/presentation/profile/ProfileViewModel.kt @@ -7,14 +7,17 @@ import androidx.navigation.toRoute import com.flint.core.common.util.UiState import com.flint.core.common.util.suspendRunCatching import com.flint.core.navigation.Route +import com.flint.domain.model.bookmark.BookmarkChange import com.flint.domain.model.user.KeywordListModel import com.flint.domain.repository.AuthRepository +import com.flint.domain.repository.BookmarkRepository import com.flint.domain.repository.ContentRepository import com.flint.domain.repository.UserRepository import com.flint.presentation.profile.sideeffect.ProfileSideEffect import com.flint.presentation.profile.uistate.ProfileSectionData import com.flint.presentation.profile.uistate.ProfileUiState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -31,7 +34,8 @@ class ProfileViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val authRepository: AuthRepository, private val userRepository: UserRepository, - private val contentRepository: ContentRepository + private val contentRepository: ContentRepository, + private val bookmarkRepository: BookmarkRepository, ) : ViewModel() { val userId = savedStateHandle.toRoute().userId @@ -42,6 +46,49 @@ class ProfileViewModel @Inject constructor( private val _sideEffect = MutableSharedFlow() val sideEffect = _sideEffect.asSharedFlow() + init { + getProfile() + observeBookmarkChanges() + } + + private fun observeBookmarkChanges() { + viewModelScope.launch { + bookmarkRepository.bookmarkChanges.collect { change -> + _uiState.update { state -> + val data = (state.sectionData as? UiState.Success)?.data ?: return@update state + val updated = when (change) { + is BookmarkChange.Content -> { + val updatedContents = if (change.isBookmarked) { + data.savedContents + } else { + val filtered = data.savedContents.contents + .filter { it.id != change.id } + .toPersistentList() + data.savedContents.copy( + contents = filtered, + totalCount = filtered.size, + ) + } + data.copy(savedContents = updatedContents) + } + is BookmarkChange.Collection -> { + val updatedCollections = if (change.isBookmarked) { + data.savedCollections + } else { + val filtered = data.savedCollections.collections + .filter { it.id != change.id } + .toPersistentList() + data.savedCollections.copy(collections = filtered) + } + data.copy(savedCollections = updatedCollections) + } + } + state.copy(sectionData = UiState.Success(updated)) + } + } + } + } + fun getProfile() { viewModelScope.launch { userRepository.getUserProfile(userId = userId) diff --git a/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt b/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt index d445e112..667fafe2 100644 --- a/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt +++ b/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt @@ -11,6 +11,7 @@ import com.flint.domain.repository.ContentRepository import com.flint.domain.repository.UserRepository import com.flint.presentation.profile.uistate.SavedContentUiState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -87,7 +88,23 @@ class SavedContentViewModel @Inject constructor( bookmarkRepository.toggleContentBookmark(contentId) .onSuccess { isBookmarked -> Timber.d("toggleBookmark success: isBookmarked=$isBookmarked") - loadBookmarkedContents(showLoading = false) + // API 재호출 없이 로컬 상태만 업데이트 + _uiState.update { state -> + val currentList = (state.contents as? UiState.Success)?.data + ?: return@update state + val updated = if (isBookmarked) { + currentList + } else { + val filtered = currentList.contents + .filter { it.id != contentId } + .toPersistentList() + currentList.copy(contents = filtered, totalCount = filtered.size) + } + state.copy( + contents = if (updated.contents.isEmpty()) UiState.Empty + else UiState.Success(updated) + ) + } } .onFailure { throwable -> val isMinLimitError = (throwable as? HttpException) From b6576a7f6fd75c03c38c8167affe2a1c6ac27e4d Mon Sep 17 00:00:00 2001 From: ckals413 Date: Wed, 10 Jun 2026 03:01:56 +0900 Subject: [PATCH 11/14] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=9E=AC=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EC=A4=91=20=EB=A1=9C=EB=94=A9=20=EC=95=A0=EB=8B=88=EB=A9=94?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/profile/ProfileScreen.kt | 1 + .../presentation/profile/ProfileViewModel.kt | 2 ++ .../component/ProfileKeywordSection.kt | 30 ++++++++++++++++--- .../profile/uistate/ProfileUiState.kt | 1 + 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt b/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt index 17460e97..e0e1f7e3 100644 --- a/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt +++ b/app/src/main/java/com/flint/presentation/profile/ProfileScreen.kt @@ -203,6 +203,7 @@ private fun ProfileScreen( keywordList = sectionData.data.keywords, isMyProfile = uiState.userId == null, isRecalculatable = uiState.profile.keywordRecalculatable, + isRecalculating = uiState.isRecalculating, showInfoModal = showInfoModal, onInfoClick = { showInfoModal = !showInfoModal }, onRefreshClick = onRefreshClick, diff --git a/app/src/main/java/com/flint/presentation/profile/ProfileViewModel.kt b/app/src/main/java/com/flint/presentation/profile/ProfileViewModel.kt index 1af92ccf..9d2d2e68 100644 --- a/app/src/main/java/com/flint/presentation/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/flint/presentation/profile/ProfileViewModel.kt @@ -146,6 +146,7 @@ class ProfileViewModel @Inject constructor( } fun recalculateKeywords() = viewModelScope.launch { + _uiState.update { it.copy(isRecalculating = true) } userRepository.recalculateKeywords() .onSuccess { // 버튼 즉시 비활성화 후 키워드 재조회 @@ -160,6 +161,7 @@ class ProfileViewModel @Inject constructor( .onFailure { Timber.e(it) } } .onFailure { Timber.e(it) } + _uiState.update { it.copy(isRecalculating = false) } } fun easterEggWithdraw() = viewModelScope.launch { diff --git a/app/src/main/java/com/flint/presentation/profile/component/ProfileKeywordSection.kt b/app/src/main/java/com/flint/presentation/profile/component/ProfileKeywordSection.kt index 160d31b0..98bb1f44 100644 --- a/app/src/main/java/com/flint/presentation/profile/component/ProfileKeywordSection.kt +++ b/app/src/main/java/com/flint/presentation/profile/component/ProfileKeywordSection.kt @@ -1,5 +1,10 @@ package com.flint.presentation.profile.component +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.offset @@ -21,6 +26,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.vectorResource @@ -43,6 +49,7 @@ fun ProfileKeywordSection( keywordList: KeywordListModel, isMyProfile: Boolean, isRecalculatable: Boolean, + isRecalculating: Boolean = false, showInfoModal: Boolean, onInfoClick: () -> Unit, onRefreshClick: () -> Unit, @@ -91,6 +98,7 @@ fun ProfileKeywordSection( ProfileRefreshButton( onRefreshClick = onRefreshClick, isEnabled = isRecalculatable, + isRecalculating = isRecalculating, ) } } @@ -122,21 +130,35 @@ fun ProfileKeywordSection( private fun ProfileRefreshButton( onRefreshClick: () -> Unit, isEnabled: Boolean, + isRecalculating: Boolean = false, modifier: Modifier = Modifier, ) { val iconTint = if (isEnabled) FlintTheme.colors.secondary400 else FlintTheme.colors.gray400 + + val infiniteTransition = rememberInfiniteTransition(label = "refresh_rotation") + val rotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 800, easing = LinearEasing), + ), + label = "refresh_rotation_value", + ) + Column( verticalArrangement = Arrangement.spacedBy(4.dp), horizontalAlignment = Alignment.CenterHorizontally, - modifier = - modifier.noRippleClickable( - onClick = { if (isEnabled) onRefreshClick() }, - ), + modifier = modifier.noRippleClickable( + onClick = { if (isEnabled && !isRecalculating) onRefreshClick() }, + ), ) { Icon( imageVector = ImageVector.vectorResource(R.drawable.ic_refresh), contentDescription = null, tint = iconTint, + modifier = Modifier.graphicsLayer { + rotationZ = if (isRecalculating) rotation else 0f + }, ) Text( text = "업데이트", diff --git a/app/src/main/java/com/flint/presentation/profile/uistate/ProfileUiState.kt b/app/src/main/java/com/flint/presentation/profile/uistate/ProfileUiState.kt index 41534097..6fc6793c 100644 --- a/app/src/main/java/com/flint/presentation/profile/uistate/ProfileUiState.kt +++ b/app/src/main/java/com/flint/presentation/profile/uistate/ProfileUiState.kt @@ -12,6 +12,7 @@ data class ProfileUiState( val userId: String? = null, val profile: UserProfileResponseModel = UserProfileResponseModel.Empty, val sectionData: UiState = UiState.Loading, + val isRecalculating: Boolean = false, ) { companion object { val Empty = From 77cff3c6fe5fac7c6cd27fcc1a0f247b4627a72a Mon Sep 17 00:00:00 2001 From: ckals413 Date: Wed, 10 Jun 2026 03:19:42 +0900 Subject: [PATCH 12/14] =?UTF-8?q?feat:=20=EB=B6=81=EB=A7=88=ED=81=AC?= =?UTF-8?q?=ED=95=9C=20=EC=BD=98=ED=85=90=EC=B8=A0=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B2=BD=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EB=B6=81=EB=A7=88=ED=81=AC=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/java/com/flint/data/api/UserApi.kt | 2 +- .../dto/content/response/BookmarkedContentListResponseDto.kt | 2 ++ .../main/java/com/flint/domain/mapper/content/ContentMapper.kt | 1 + .../flint/domain/model/content/BookmarkedContentListModel.kt | 1 + 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/flint/data/api/UserApi.kt b/app/src/main/java/com/flint/data/api/UserApi.kt index eebe1920..929f8ae6 100644 --- a/app/src/main/java/com/flint/data/api/UserApi.kt +++ b/app/src/main/java/com/flint/data/api/UserApi.kt @@ -37,7 +37,7 @@ interface UserApi { ): BaseResponse // 사용자별 북마크한 콘텐츠 목록 조회 - @GET("/api/v1/contents/{userId}/bookmarked-contents") + @GET("/api/v1/users/{userId}/bookmarked-contents") suspend fun getBookmarkedContentListByUserId( @Path("userId") userId: String ): BaseResponse diff --git a/app/src/main/java/com/flint/data/dto/content/response/BookmarkedContentListResponseDto.kt b/app/src/main/java/com/flint/data/dto/content/response/BookmarkedContentListResponseDto.kt index 311c9c5c..a987ca17 100644 --- a/app/src/main/java/com/flint/data/dto/content/response/BookmarkedContentListResponseDto.kt +++ b/app/src/main/java/com/flint/data/dto/content/response/BookmarkedContentListResponseDto.kt @@ -23,6 +23,8 @@ data class BookmarkedContentResponseDto( val imageUrl: String, @SerialName("bookmarkCount") val bookmarkCount: Int, + @SerialName("isBookmarked") + val isBookmarked: Boolean, @SerialName("getOttSimpleList") val getOttSimpleList: List ) diff --git a/app/src/main/java/com/flint/domain/mapper/content/ContentMapper.kt b/app/src/main/java/com/flint/domain/mapper/content/ContentMapper.kt index 942b2ff1..a6b1bb78 100644 --- a/app/src/main/java/com/flint/domain/mapper/content/ContentMapper.kt +++ b/app/src/main/java/com/flint/domain/mapper/content/ContentMapper.kt @@ -24,6 +24,7 @@ fun BookmarkedContentResponseDto.toModel() : BookmarkedContentItemModel { year = year, imageUrl = imageUrl, bookmarkCount = bookmarkCount, + isBookmarked = isBookmarked, getOttSimpleList = getOttSimpleList.mapNotNull { ottSimple -> runCatching { OttType.valueOf(ottSimple.ottName) }.getOrNull() } diff --git a/app/src/main/java/com/flint/domain/model/content/BookmarkedContentListModel.kt b/app/src/main/java/com/flint/domain/model/content/BookmarkedContentListModel.kt index fddd06a3..630970ff 100644 --- a/app/src/main/java/com/flint/domain/model/content/BookmarkedContentListModel.kt +++ b/app/src/main/java/com/flint/domain/model/content/BookmarkedContentListModel.kt @@ -36,5 +36,6 @@ data class BookmarkedContentItemModel( val year: Int = 0, val imageUrl: String = "", val bookmarkCount: Int = 0, + val isBookmarked: Boolean = false, val getOttSimpleList: List = emptyList() ) \ No newline at end of file From 6d0d6e149e9aa14ceb2aac099e112cca56960cad Mon Sep 17 00:00:00 2001 From: ckals413 Date: Fri, 12 Jun 2026 00:09:51 +0900 Subject: [PATCH 13/14] =?UTF-8?q?feat:=20=EB=82=B4=20=EB=B6=81=EB=A7=88?= =?UTF-8?q?=ED=81=AC=20=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EC=BB=A4?= =?UTF-8?q?=EC=84=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/flint/data/api/ContentApi.kt | 10 ++-- .../BookmarkedContentListResponseDto.kt | 23 +++++++- .../domain/mapper/content/ContentMapper.kt | 10 ++++ .../flint/domain/repository/UserRepository.kt | 26 +++++++++- .../profile/SavedContentScreen.kt | 2 +- .../profile/SavedContentViewModel.kt | 52 ++++++++++++------- 6 files changed, 97 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/flint/data/api/ContentApi.kt b/app/src/main/java/com/flint/data/api/ContentApi.kt index 68e3f5a4..39ec60f6 100644 --- a/app/src/main/java/com/flint/data/api/ContentApi.kt +++ b/app/src/main/java/com/flint/data/api/ContentApi.kt @@ -1,15 +1,19 @@ package com.flint.data.api import com.flint.data.dto.base.BaseResponse -import com.flint.data.dto.content.response.BookmarkedContentListResponseDto +import com.flint.data.dto.content.response.MyBookmarkedContentListResponseDto import com.flint.data.dto.ott.response.OttListResponseDto import retrofit2.http.GET import retrofit2.http.Path +import retrofit2.http.Query interface ContentApi { - // 북마크한 콘텐츠 목록 조회 + // 내 북마크한 콘텐츠 목록 조회 (커서 페이지네이션) @GET("/api/v1/contents/bookmarks") - suspend fun getBookmarkedContentList(): BaseResponse + suspend fun getBookmarkedContentList( + @Query("cursor") cursor: String? = null, + @Query("size") size: Int = 10, + ): BaseResponse // 콘텐츠별 OTT 목록 조회 @GET("/api/v1/contents/ott/{contentId}") diff --git a/app/src/main/java/com/flint/data/dto/content/response/BookmarkedContentListResponseDto.kt b/app/src/main/java/com/flint/data/dto/content/response/BookmarkedContentListResponseDto.kt index a987ca17..e2043da4 100644 --- a/app/src/main/java/com/flint/data/dto/content/response/BookmarkedContentListResponseDto.kt +++ b/app/src/main/java/com/flint/data/dto/content/response/BookmarkedContentListResponseDto.kt @@ -3,6 +3,7 @@ package com.flint.data.dto.content.response import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +// /api/v1/users/{userId}/bookmarked-contents 응답 (타 유저) @Serializable data class BookmarkedContentListResponseDto( @SerialName("totalCount") @@ -11,6 +12,25 @@ data class BookmarkedContentListResponseDto( val contents: List ) +// /api/v1/contents/bookmarks 응답 (내 프로필, 커서 페이지네이션) +@Serializable +data class MyBookmarkedContentListResponseDto( + @SerialName("data") + val data: List, + @SerialName("meta") + val meta: BookmarkCursorMetaDto +) + +@Serializable +data class BookmarkCursorMetaDto( + @SerialName("type") + val type: String, + @SerialName("returned") + val returned: Int, + @SerialName("nextCursor") + val nextCursor: String? = null, +) + @Serializable data class BookmarkedContentResponseDto( @SerialName("id") @@ -23,8 +43,9 @@ data class BookmarkedContentResponseDto( val imageUrl: String, @SerialName("bookmarkCount") val bookmarkCount: Int, + // 내 북마크 목록 응답에는 isBookmarked 없음 → 기본값 true @SerialName("isBookmarked") - val isBookmarked: Boolean, + val isBookmarked: Boolean = true, @SerialName("getOttSimpleList") val getOttSimpleList: List ) diff --git a/app/src/main/java/com/flint/domain/mapper/content/ContentMapper.kt b/app/src/main/java/com/flint/domain/mapper/content/ContentMapper.kt index a6b1bb78..803ff7ab 100644 --- a/app/src/main/java/com/flint/domain/mapper/content/ContentMapper.kt +++ b/app/src/main/java/com/flint/domain/mapper/content/ContentMapper.kt @@ -2,6 +2,7 @@ package com.flint.domain.mapper.content import com.flint.data.dto.content.response.BookmarkedContentListResponseDto import com.flint.data.dto.content.response.BookmarkedContentResponseDto +import com.flint.data.dto.content.response.MyBookmarkedContentListResponseDto import com.flint.data.dto.content.response.OttSimpleResponseDto import com.flint.data.dto.search.SearchBookmarkedContentsResponseDto import com.flint.domain.model.content.BookmarkedContentItemModel @@ -10,6 +11,7 @@ import com.flint.domain.model.content.ContentModel import com.flint.domain.type.OttType import kotlinx.collections.immutable.toImmutableList +// /api/v1/users/{userId}/bookmarked-contents (타 유저) fun BookmarkedContentListResponseDto.toModel() : BookmarkedContentListModel { return BookmarkedContentListModel( totalCount = totalCount, @@ -17,6 +19,14 @@ fun BookmarkedContentListResponseDto.toModel() : BookmarkedContentListModel { ) } +// /api/v1/contents/bookmarks (내 프로필) +fun MyBookmarkedContentListResponseDto.toModel() : BookmarkedContentListModel { + return BookmarkedContentListModel( + totalCount = data.size, + contents = data.map { it.toModel() }.toImmutableList() + ) +} + fun BookmarkedContentResponseDto.toModel() : BookmarkedContentItemModel { return BookmarkedContentItemModel( id = id, diff --git a/app/src/main/java/com/flint/domain/repository/UserRepository.kt b/app/src/main/java/com/flint/domain/repository/UserRepository.kt index 83a71e88..dfafe010 100644 --- a/app/src/main/java/com/flint/domain/repository/UserRepository.kt +++ b/app/src/main/java/com/flint/domain/repository/UserRepository.kt @@ -2,21 +2,25 @@ package com.flint.domain.repository import com.flint.core.common.util.DataStoreKey.USER_ID import com.flint.core.common.util.suspendRunCatching +import com.flint.data.api.ContentApi import com.flint.data.api.UserApi import com.flint.data.local.PreferencesManager import com.flint.domain.mapper.collection.toModel import com.flint.domain.mapper.content.toModel import com.flint.domain.mapper.user.toModel import com.flint.domain.model.collection.CollectionListModel +import com.flint.domain.model.content.BookmarkedContentItemModel import com.flint.domain.model.content.BookmarkedContentListModel import com.flint.domain.model.user.KeywordListModel import com.flint.domain.model.user.NicknameCheckModel import com.flint.domain.model.user.UserProfileResponseModel +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.first import javax.inject.Inject class UserRepository @Inject constructor( private val apiService: UserApi, + private val contentApi: ContentApi, private val preferencesManager: PreferencesManager, ) { private suspend fun myUserId(): String { @@ -60,8 +64,26 @@ class UserRepository @Inject constructor( } // 사용자별 북마크한 콘텐츠 목록 조회 - suspend fun getUserBookmarkedContents(userId: String?) : Result = - suspendRunCatching { apiService.getBookmarkedContentListByUserId(userId ?: myUserId()).data.toModel() } + // - 내 프로필 (userId == null): GET /api/v1/contents/bookmarks (커서 페이지네이션 전체 로드) + // - 타 유저 (userId != null): GET /api/v1/users/{userId}/bookmarked-contents + suspend fun getUserBookmarkedContents(userId: String?): Result = + suspendRunCatching { + if (userId == null) { + val allContents = mutableListOf() + var cursor: String? = null + do { + val page = contentApi.getBookmarkedContentList(cursor = cursor).data + allContents.addAll(page.data.map { it.toModel() }) + cursor = page.meta.nextCursor + } while (cursor != null) + BookmarkedContentListModel( + totalCount = allContents.size, + contents = allContents.toImmutableList() + ) + } else { + apiService.getBookmarkedContentListByUserId(userId).data.toModel() + } + } // 취향 키워드 재계산 suspend fun recalculateKeywords(): Result = diff --git a/app/src/main/java/com/flint/presentation/profile/SavedContentScreen.kt b/app/src/main/java/com/flint/presentation/profile/SavedContentScreen.kt index 2acaf670..4d61bd86 100644 --- a/app/src/main/java/com/flint/presentation/profile/SavedContentScreen.kt +++ b/app/src/main/java/com/flint/presentation/profile/SavedContentScreen.kt @@ -193,7 +193,7 @@ private fun SavedContentList( modifier = Modifier.animateItem(), onBookmarkClick = { onBookmarkClick(content.id) }, onMoreClick = {}, - isBookmarked = true, + isBookmarked = content.isBookmarked, bookmarkCount = content.bookmarkCount, imageUrl = content.imageUrl, title = content.title, diff --git a/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt b/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt index 667fafe2..1f7dbc1d 100644 --- a/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt +++ b/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt @@ -79,31 +79,45 @@ class SavedContentViewModel @Inject constructor( } - // 북마크 토글 (저장 취소) — 내 프로필에서만 동작 - // 저장된 작품이 MIN_REQUIRED_COUNT(5)개일 때는 취소를 막고 안내 모달을 노출한다. + // 북마크 토글 + // - 내 프로필: 북마크 취소 시 목록에서 제거 (MIN_REQUIRED_COUNT 제한 있음) + // - 타 유저 프로필: isBookmarked 필드만 토글 (목록은 상대방 저장 목록이므로 제거 X) fun toggleBookmark(contentId: String) { - if (userId != null) return // 타유저 프로필에서는 북마크 토글 불가 viewModelScope.launch { - Timber.d("toggleBookmark: calling API for contentId=$contentId") + Timber.d("toggleBookmark: contentId=$contentId, userId=$userId") bookmarkRepository.toggleContentBookmark(contentId) .onSuccess { isBookmarked -> Timber.d("toggleBookmark success: isBookmarked=$isBookmarked") - // API 재호출 없이 로컬 상태만 업데이트 - _uiState.update { state -> - val currentList = (state.contents as? UiState.Success)?.data - ?: return@update state - val updated = if (isBookmarked) { - currentList - } else { - val filtered = currentList.contents - .filter { it.id != contentId } - .toPersistentList() - currentList.copy(contents = filtered, totalCount = filtered.size) + if (userId == null) { + // 내 프로필: 북마크 취소 시 목록에서 제거 + _uiState.update { state -> + val currentList = (state.contents as? UiState.Success)?.data + ?: return@update state + val updated = if (isBookmarked) { + currentList + } else { + val filtered = currentList.contents + .filter { it.id != contentId } + .toPersistentList() + currentList.copy(contents = filtered, totalCount = filtered.size) + } + state.copy( + contents = if (updated.contents.isEmpty()) UiState.Empty + else UiState.Success(updated) + ) + } + } else { + // 타 유저 프로필: isBookmarked 필드만 업데이트 (목록에서 제거 X) + _uiState.update { state -> + val currentList = (state.contents as? UiState.Success)?.data + ?: return@update state + val updated = currentList.copy( + contents = currentList.contents + .map { if (it.id == contentId) it.copy(isBookmarked = isBookmarked) else it } + .toPersistentList() + ) + state.copy(contents = UiState.Success(updated)) } - state.copy( - contents = if (updated.contents.isEmpty()) UiState.Empty - else UiState.Success(updated) - ) } } .onFailure { throwable -> From d2e2e29393b631cedac5e08e5d78c20e4bda9c40 Mon Sep 17 00:00:00 2001 From: ckals413 Date: Sun, 14 Jun 2026 01:33:23 +0900 Subject: [PATCH 14/14] =?UTF-8?q?feat:=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../di/interceptor/NetworkErrorInterceptor.kt | 21 ++++++++-- .../flint/data/dto/base/ErrorResponseDto.kt | 12 ++++++ .../user/response/UserProfileResponseDto.kt | 14 +++---- .../domain/mapper/content/ContentMapper.kt | 4 +- .../domain/repository/BookmarkRepository.kt | 28 ++++++------- .../presentation/profile/ProfileViewModel.kt | 28 +++++++++---- .../profile/SavedContentScreen.kt | 26 ++++++++++++- .../profile/SavedContentViewModel.kt | 39 +++++++++++-------- 8 files changed, 121 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/com/flint/data/dto/base/ErrorResponseDto.kt diff --git a/app/src/main/java/com/flint/data/di/interceptor/NetworkErrorInterceptor.kt b/app/src/main/java/com/flint/data/di/interceptor/NetworkErrorInterceptor.kt index 7b9ae6cd..99b81ebe 100644 --- a/app/src/main/java/com/flint/data/di/interceptor/NetworkErrorInterceptor.kt +++ b/app/src/main/java/com/flint/data/di/interceptor/NetworkErrorInterceptor.kt @@ -1,9 +1,12 @@ package com.flint.data.di.interceptor +import com.flint.data.dto.base.ErrorResponseDto import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json import okhttp3.Interceptor import okhttp3.Response import java.io.IOException @@ -13,6 +16,7 @@ import javax.inject.Inject class NetworkErrorInterceptor @Inject constructor( private val networkErrorManager: NetworkErrorManager, + private val json: Json, ) : Interceptor { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -26,9 +30,7 @@ class NetworkErrorInterceptor @Inject constructor( when (response.code) { in 300..599 -> { // errorCode 필드가 있으면 앱 레이어에서 직접 처리하는 비즈니스 에러 — 글로벌 에러 emit 생략 - val isBusinessError = response.peekBody(Long.MAX_VALUE) - .string() - .contains("\"errorCode\"") + val isBusinessError = response.isBusinessError() if (!isBusinessError) { scope.launch { networkErrorManager.emitError( @@ -67,4 +69,17 @@ class NetworkErrorInterceptor @Inject constructor( throw e } } + + private fun Response.isBusinessError(): Boolean { + val body = runCatching { peekBody(ERROR_BODY_PEEK_BYTES).string() }.getOrNull() + ?: return false + + return runCatching { + json.decodeFromString(body).errorCode != null + }.getOrDefault(false) + } + + private companion object { + const val ERROR_BODY_PEEK_BYTES = 64 * 1024L + } } diff --git a/app/src/main/java/com/flint/data/dto/base/ErrorResponseDto.kt b/app/src/main/java/com/flint/data/dto/base/ErrorResponseDto.kt new file mode 100644 index 00000000..c1639470 --- /dev/null +++ b/app/src/main/java/com/flint/data/dto/base/ErrorResponseDto.kt @@ -0,0 +1,12 @@ +package com.flint.data.dto.base + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ErrorResponseDto( + @SerialName("errorCode") + val errorCode: String? = null, + @SerialName("message") + val message: String? = null, +) diff --git a/app/src/main/java/com/flint/data/dto/user/response/UserProfileResponseDto.kt b/app/src/main/java/com/flint/data/dto/user/response/UserProfileResponseDto.kt index 04090b63..03e13910 100644 --- a/app/src/main/java/com/flint/data/dto/user/response/UserProfileResponseDto.kt +++ b/app/src/main/java/com/flint/data/dto/user/response/UserProfileResponseDto.kt @@ -1,18 +1,18 @@ package com.flint.data.dto.user.response -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class UserProfileResponseDto( - @SerializedName("id") + @SerialName("id") val id: String, - @SerializedName("profileImageUrl") + @SerialName("profileImageUrl") val profileImageUrl: String?, - @SerializedName("isFliner") + @SerialName("isFliner") val isFliner: Boolean, - @SerializedName("nickname") + @SerialName("nickname") val nickname: String, - @SerializedName("keywordRecalculatable") + @SerialName("keywordRecalculatable") val keywordRecalculatable: Boolean = false, -) \ No newline at end of file +) diff --git a/app/src/main/java/com/flint/domain/mapper/content/ContentMapper.kt b/app/src/main/java/com/flint/domain/mapper/content/ContentMapper.kt index 803ff7ab..d66b3bab 100644 --- a/app/src/main/java/com/flint/domain/mapper/content/ContentMapper.kt +++ b/app/src/main/java/com/flint/domain/mapper/content/ContentMapper.kt @@ -22,7 +22,7 @@ fun BookmarkedContentListResponseDto.toModel() : BookmarkedContentListModel { // /api/v1/contents/bookmarks (내 프로필) fun MyBookmarkedContentListResponseDto.toModel() : BookmarkedContentListModel { return BookmarkedContentListModel( - totalCount = data.size, + totalCount = meta.returned, contents = data.map { it.toModel() }.toImmutableList() ) } @@ -52,4 +52,4 @@ fun SearchBookmarkedContentsResponseDto.toModel() : List { year = it.year ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/flint/domain/repository/BookmarkRepository.kt b/app/src/main/java/com/flint/domain/repository/BookmarkRepository.kt index 3a175a83..242bf4f3 100644 --- a/app/src/main/java/com/flint/domain/repository/BookmarkRepository.kt +++ b/app/src/main/java/com/flint/domain/repository/BookmarkRepository.kt @@ -23,20 +23,20 @@ class BookmarkRepository @Inject constructor( } // 컬렉션 북마크 토글 - suspend fun toggleCollectionBookmark(collectionId: String): Result = - suspendRunCatching { api.toggleCollectionBookmark(collectionId).data } - .also { result -> - result.onSuccess { isBookmarked -> - _bookmarkChanges.emit(BookmarkChange.Collection(collectionId, isBookmarked)) - } - } + suspend fun toggleCollectionBookmark(collectionId: String): Result { + val result = suspendRunCatching { api.toggleCollectionBookmark(collectionId).data } + result.getOrNull()?.let { isBookmarked -> + _bookmarkChanges.emit(BookmarkChange.Collection(collectionId, isBookmarked)) + } + return result + } // 콘텐츠 북마크 토글 - suspend fun toggleContentBookmark(contentId: String): Result = - suspendRunCatching { api.toggleContentBookmark(contentId).data } - .also { result -> - result.onSuccess { isBookmarked -> - _bookmarkChanges.emit(BookmarkChange.Content(contentId, isBookmarked)) - } - } + suspend fun toggleContentBookmark(contentId: String): Result { + val result = suspendRunCatching { api.toggleContentBookmark(contentId).data } + result.getOrNull()?.let { isBookmarked -> + _bookmarkChanges.emit(BookmarkChange.Content(contentId, isBookmarked)) + } + return result + } } diff --git a/app/src/main/java/com/flint/presentation/profile/ProfileViewModel.kt b/app/src/main/java/com/flint/presentation/profile/ProfileViewModel.kt index 9d2d2e68..c199814b 100644 --- a/app/src/main/java/com/flint/presentation/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/flint/presentation/profile/ProfileViewModel.kt @@ -58,15 +58,29 @@ class ProfileViewModel @Inject constructor( val data = (state.sectionData as? UiState.Success)?.data ?: return@update state val updated = when (change) { is BookmarkChange.Content -> { - val updatedContents = if (change.isBookmarked) { - data.savedContents + val updatedContents = if (userId == null) { + if (change.isBookmarked) { + data.savedContents + } else { + val filtered = data.savedContents.contents + .filter { it.id != change.id } + .toPersistentList() + data.savedContents.copy( + contents = filtered, + totalCount = maxOf(data.savedContents.totalCount - 1, 0), + ) + } } else { - val filtered = data.savedContents.contents - .filter { it.id != change.id } - .toPersistentList() data.savedContents.copy( - contents = filtered, - totalCount = filtered.size, + contents = data.savedContents.contents + .map { + if (it.id == change.id) { + it.copy(isBookmarked = change.isBookmarked) + } else { + it + } + } + .toPersistentList(), ) } data.copy(savedContents = updatedContents) diff --git a/app/src/main/java/com/flint/presentation/profile/SavedContentScreen.kt b/app/src/main/java/com/flint/presentation/profile/SavedContentScreen.kt index 4d61bd86..74f78722 100644 --- a/app/src/main/java/com/flint/presentation/profile/SavedContentScreen.kt +++ b/app/src/main/java/com/flint/presentation/profile/SavedContentScreen.kt @@ -212,6 +212,8 @@ private object SavedContentPreviewData { title = "은하수를 여행하는 히치하이커를 위한 안내서", year = 2005, imageUrl = "", + bookmarkCount = 42, + isBookmarked = true, getOttSimpleList = listOf( OttType.Netflix, OttType.Disney, @@ -223,6 +225,8 @@ private object SavedContentPreviewData { title = "해리포터와 불의잔", year = 2005, imageUrl = "", + bookmarkCount = 18, + isBookmarked = true, getOttSimpleList = listOf( OttType.Netflix, OttType.CoupangPlay @@ -233,6 +237,8 @@ private object SavedContentPreviewData { title = "해리포터와 불의잔", year = 2005, imageUrl = "", + bookmarkCount = 7, + isBookmarked = false, getOttSimpleList = listOf(OttType.Netflix), ), BookmarkedContentItemModel( @@ -240,6 +246,8 @@ private object SavedContentPreviewData { title = "해리포터와 불의잔", year = 2005, imageUrl = "", + bookmarkCount = 29, + isBookmarked = true, getOttSimpleList = listOf(OttType.Netflix), ), BookmarkedContentItemModel( @@ -247,6 +255,8 @@ private object SavedContentPreviewData { title = "해리포터와 불의잔", year = 2005, imageUrl = "", + bookmarkCount = 13, + isBookmarked = false, getOttSimpleList = listOf(OttType.Netflix), ), BookmarkedContentItemModel( @@ -254,6 +264,8 @@ private object SavedContentPreviewData { title = "해리포터와 불의잔", year = 2005, imageUrl = "", + bookmarkCount = 51, + isBookmarked = true, getOttSimpleList = listOf(OttType.Netflix), ), BookmarkedContentItemModel( @@ -261,6 +273,8 @@ private object SavedContentPreviewData { title = "해리포터와 불의잔", year = 2005, imageUrl = "", + bookmarkCount = 4, + isBookmarked = false, getOttSimpleList = listOf(OttType.Netflix), ), BookmarkedContentItemModel( @@ -268,6 +282,8 @@ private object SavedContentPreviewData { title = "해리포터와 불의잔", year = 2005, imageUrl = "", + bookmarkCount = 36, + isBookmarked = true, getOttSimpleList = listOf(OttType.Netflix), ), ) @@ -280,7 +296,10 @@ private fun SavedContentScreenPreview() { SavedContentScreen( uiState = SavedContentUiState( contents = UiState.Success( - BookmarkedContentListModel(contents = SavedContentPreviewData.FakeList), + BookmarkedContentListModel( + totalCount = SavedContentPreviewData.FakeList.size, + contents = SavedContentPreviewData.FakeList, + ), ), ), navigateUp = {}, @@ -329,7 +348,10 @@ private fun SavedContentScreenRestrictionModalPreview() { SavedContentScreen( uiState = SavedContentUiState( contents = UiState.Success( - BookmarkedContentListModel(contents = SavedContentPreviewData.FakeList), + BookmarkedContentListModel( + totalCount = SavedContentPreviewData.FakeList.size, + contents = SavedContentPreviewData.FakeList, + ), ), showBookmarkRestrictionModal = true, ), diff --git a/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt b/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt index 1f7dbc1d..f709b5b1 100644 --- a/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt +++ b/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt @@ -6,8 +6,8 @@ import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import com.flint.core.common.util.UiState import com.flint.core.navigation.Route +import com.flint.data.dto.base.ErrorResponseDto import com.flint.domain.repository.BookmarkRepository -import com.flint.domain.repository.ContentRepository import com.flint.domain.repository.UserRepository import com.flint.presentation.profile.uistate.SavedContentUiState import dagger.hilt.android.lifecycle.HiltViewModel @@ -17,18 +17,17 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import retrofit2.HttpException import timber.log.Timber import javax.inject.Inject @HiltViewModel class SavedContentViewModel @Inject constructor( - private val contentRepository: ContentRepository, private val userRepository: UserRepository, private val bookmarkRepository: BookmarkRepository, + private val json: Json, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -99,7 +98,10 @@ class SavedContentViewModel @Inject constructor( val filtered = currentList.contents .filter { it.id != contentId } .toPersistentList() - currentList.copy(contents = filtered, totalCount = filtered.size) + currentList.copy( + contents = filtered, + totalCount = maxOf(currentList.totalCount - 1, 0), + ) } state.copy( contents = if (updated.contents.isEmpty()) UiState.Empty @@ -121,17 +123,7 @@ class SavedContentViewModel @Inject constructor( } } .onFailure { throwable -> - val isMinLimitError = (throwable as? HttpException) - ?.response()?.errorBody()?.string() - ?.let { body -> - runCatching { - Json.parseToJsonElement(body) - .jsonObject["errorCode"] - ?.jsonPrimitive?.content == "BOOKMARK.CONTENT_MIN_LIMIT" - }.getOrDefault(false) - } ?: false - - if (isMinLimitError) { + if (throwable.isContentMinLimitError()) { _uiState.update { it.copy(showBookmarkRestrictionModal = true) } } else { Timber.e(throwable, "toggleBookmark failed: contentId=$contentId") @@ -144,4 +136,19 @@ class SavedContentViewModel @Inject constructor( fun dismissBookmarkRestrictionModal() { _uiState.update { it.copy(showBookmarkRestrictionModal = false) } } + + private fun Throwable.isContentMinLimitError(): Boolean { + val body = (this as? HttpException)?.response()?.errorBody()?.string() + ?: return false + + return runCatching { + json.decodeFromString(body).errorCode == CONTENT_MIN_LIMIT_ERROR_CODE + }.onFailure { + Timber.w(it, "Failed to parse bookmark error response") + }.getOrDefault(false) + } + + private companion object { + const val CONTENT_MIN_LIMIT_ERROR_CODE = "BOOKMARK.CONTENT_MIN_LIMIT" + } }