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 66dd2a26..058cb2bd 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/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/api/UserApi.kt b/app/src/main/java/com/flint/data/api/UserApi.kt index 7f5ec759..e8bbef74 100644 --- a/app/src/main/java/com/flint/data/api/UserApi.kt +++ b/app/src/main/java/com/flint/data/api/UserApi.kt @@ -10,6 +10,8 @@ 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.PATCH import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.PUT @@ -17,6 +19,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( @@ -36,7 +42,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 @@ -61,8 +67,10 @@ interface UserApi { @Path("userId") userId: String, ): BaseResponse - // 취향 키워드 재계산 - + // 취향 키워드 재계산 (응답 body에 data 필드xx) + @PATCH("/api/v1/users/me/keywords/recalculate") + suspend fun recalculateKeywords(): Response + // 닉네임 수정 @PUT("/api/v1/users/me/nickname") suspend fun updateNickname( 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..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) @@ -25,13 +29,17 @@ 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.isBusinessError() + if (!isBusinessError) { + scope.launch { + networkErrorManager.emitError( + NetworkError.ConnectionError( + code = response.code, + message = response.message + ) ) - ) + } } } } @@ -61,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/content/response/BookmarkedContentListResponseDto.kt b/app/src/main/java/com/flint/data/dto/content/response/BookmarkedContentListResponseDto.kt index 9066b570..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,12 +3,34 @@ 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") + val totalCount: Int, @SerialName("contents") 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") @@ -19,6 +41,11 @@ data class BookmarkedContentResponseDto( val year: Int, @SerialName("imageUrl") val imageUrl: String, + @SerialName("bookmarkCount") + val bookmarkCount: Int, + // 내 북마크 목록 응답에는 isBookmarked 없음 → 기본값 true + @SerialName("isBookmarked") + val isBookmarked: Boolean = true, @SerialName("getOttSimpleList") val getOttSimpleList: List ) 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..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,16 +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") - val nickname: String -) \ No newline at end of file + @SerialName("nickname") + val nickname: String, + @SerialName("keywordRecalculatable") + val keywordRecalculatable: Boolean = false, +) 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..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 @@ -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,18 +11,30 @@ 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, contents = contents.map { it.toModel() }.toImmutableList() ) } +// /api/v1/contents/bookmarks (내 프로필) +fun MyBookmarkedContentListResponseDto.toModel() : BookmarkedContentListModel { + return BookmarkedContentListModel( + totalCount = meta.returned, + contents = data.map { it.toModel() }.toImmutableList() + ) +} + fun BookmarkedContentResponseDto.toModel() : BookmarkedContentItemModel { return BookmarkedContentItemModel( id = id, title = title, year = year, imageUrl = imageUrl, + bookmarkCount = bookmarkCount, + isBookmarked = isBookmarked, getOttSimpleList = getOttSimpleList.mapNotNull { ottSimple -> runCatching { OttType.valueOf(ottSimple.ottName) }.getOrNull() } @@ -39,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/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/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/model/content/BookmarkedContentListModel.kt b/app/src/main/java/com/flint/domain/model/content/BookmarkedContentListModel.kt index d878a57f..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 @@ -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,7 @@ data class BookmarkedContentItemModel( val title: String = "", 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 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/BookmarkRepository.kt b/app/src/main/java/com/flint/domain/repository/BookmarkRepository.kt index aa482de7..242bf4f3 100644 --- a/app/src/main/java/com/flint/domain/repository/BookmarkRepository.kt +++ b/app/src/main/java/com/flint/domain/repository/BookmarkRepository.kt @@ -3,22 +3,40 @@ 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() } } // 컬렉션 북마크 토글 - suspend fun toggleCollectionBookmark(collectionId: String): Result = - suspendRunCatching { api.toggleCollectionBookmark(collectionId).data } + 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 } + 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/domain/repository/UserRepository.kt b/app/src/main/java/com/flint/domain/repository/UserRepository.kt index 67b71d47..bb63bcff 100644 --- a/app/src/main/java/com/flint/domain/repository/UserRepository.kt +++ b/app/src/main/java/com/flint/domain/repository/UserRepository.kt @@ -2,6 +2,7 @@ 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.dto.user.request.UpdateNicknameRequestDto import com.flint.data.dto.user.request.UpdateProfileImageRequestDto @@ -10,25 +11,32 @@ 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 { 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() + } } // 사용자 취향 키워드 조회 @@ -58,8 +66,30 @@ 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 = + suspendRunCatching { apiService.recalculateKeywords().also { check(it.isSuccessful) } } // 닉네임 중복 체크 suspend fun checkNickname(nickname: String): Result = 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 a4d5a26b..89206b4d 100644 --- a/app/src/main/java/com/flint/presentation/main/MainNavHost.kt +++ b/app/src/main/java/com/flint/presentation/main/MainNavHost.kt @@ -87,6 +87,7 @@ fun MainNavHost( savedContentListNavGraph( paddingValues = paddingValues, + navigateUp = navigator::navigateUp, ) exploreNavGraph( @@ -98,7 +99,7 @@ fun MainNavHost( myProfileNavGraph( paddingValues = paddingValues, navigateToCollectionList = navigator::navigateToCollectionList, - navigateToSavedContentList = navigator::navigateToSavedContent, + navigateToSavedContentList = { userId -> navigator.navigateToSavedContent(userId) }, navigateToCollectionDetail = navigator::navigateToCollectionDetail, navigateToSetting = navigator::navigateToSetting, ) @@ -107,7 +108,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 a53f9d39..e0dca1d1 100644 --- a/app/src/main/java/com/flint/presentation/main/MainNavigator.kt +++ b/app/src/main/java/com/flint/presentation/main/MainNavigator.kt @@ -151,8 +151,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 64727274..64428e98 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, navigateToSetting: () -> Unit = {}, viewModel: ProfileViewModel = hiltViewModel(), @@ -76,10 +76,6 @@ fun ProfileRoute( var ottListModel by remember { mutableStateOf(OttListModel()) } val sheetState = rememberModalBottomSheetState() - LaunchedEffect(Unit) { - viewModel.getProfile() - } - LaunchedEffect(Unit) { viewModel.sideEffect.collect { sideEffect -> when (sideEffect) { @@ -110,6 +106,7 @@ fun ProfileRoute( onContentItemClick = { contentId -> viewModel.getOttListPerContent(contentId) }, + onContentMoreClick = { navigateToSavedContentList(uiState.userId) }, onCreatedCollectionMoreClick = { navigateToCollectionList( CollectionListRouteType.CREATED, @@ -122,6 +119,7 @@ fun ProfileRoute( uiState.userId ) }, + onRefreshClick = viewModel::recalculateKeywords, onEasterEggWithdraw = viewModel::easterEggWithdraw, ) @@ -206,6 +204,8 @@ private fun ProfileScreen( nickname = uiState.profile.nickname, keywordList = sectionData.data.keywords, isMyProfile = uiState.userId == null, + isRecalculatable = uiState.profile.keywordRecalculatable, + isRecalculating = uiState.isRecalculating, showInfoModal = showInfoModal, onInfoClick = { showInfoModal = !showInfoModal }, onRefreshClick = onRefreshClick, @@ -251,8 +251,8 @@ private fun ProfileScreen( description = "${userName}님이 저장한 작품이에요", contentModelList = sectionData.data.savedContents, onItemClick = onContentItemClick, - isAllVisible = false, - onAllClick = {}, + isAllVisible = true, + onAllClick = onContentMoreClick, ) } } 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..c199814b 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,63 @@ 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 (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 { + data.savedContents.copy( + contents = data.savedContents.contents + .map { + if (it.id == change.id) { + it.copy(isBookmarked = change.isBookmarked) + } else { + it + } + } + .toPersistentList(), + ) + } + 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) @@ -98,6 +159,25 @@ class ProfileViewModel @Inject constructor( } } + fun recalculateKeywords() = viewModelScope.launch { + _uiState.update { it.copy(isRecalculating = true) } + 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) } + _uiState.update { it.copy(isRecalculating = false) } + } + fun easterEggWithdraw() = viewModelScope.launch { authRepository.withdraw() .onSuccess { 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..74f78722 100644 --- a/app/src/main/java/com/flint/presentation/profile/SavedContentScreen.kt +++ b/app/src/main/java/com/flint/presentation/profile/SavedContentScreen.kt @@ -190,10 +190,11 @@ private fun SavedContentList( key = { it.id }, ) { content -> CollectionCreateContentBookmark( + modifier = Modifier.animateItem(), onBookmarkClick = { onBookmarkClick(content.id) }, onMoreClick = {}, - isBookmarked = true, - bookmarkCount = 123, + isBookmarked = content.isBookmarked, + bookmarkCount = content.bookmarkCount, imageUrl = content.imageUrl, title = content.title, director = "감독이름", @@ -211,6 +212,8 @@ private object SavedContentPreviewData { title = "은하수를 여행하는 히치하이커를 위한 안내서", year = 2005, imageUrl = "", + bookmarkCount = 42, + isBookmarked = true, getOttSimpleList = listOf( OttType.Netflix, OttType.Disney, @@ -222,6 +225,8 @@ private object SavedContentPreviewData { title = "해리포터와 불의잔", year = 2005, imageUrl = "", + bookmarkCount = 18, + isBookmarked = true, getOttSimpleList = listOf( OttType.Netflix, OttType.CoupangPlay @@ -232,6 +237,8 @@ private object SavedContentPreviewData { title = "해리포터와 불의잔", year = 2005, imageUrl = "", + bookmarkCount = 7, + isBookmarked = false, getOttSimpleList = listOf(OttType.Netflix), ), BookmarkedContentItemModel( @@ -239,6 +246,8 @@ private object SavedContentPreviewData { title = "해리포터와 불의잔", year = 2005, imageUrl = "", + bookmarkCount = 29, + isBookmarked = true, getOttSimpleList = listOf(OttType.Netflix), ), BookmarkedContentItemModel( @@ -246,6 +255,8 @@ private object SavedContentPreviewData { title = "해리포터와 불의잔", year = 2005, imageUrl = "", + bookmarkCount = 13, + isBookmarked = false, getOttSimpleList = listOf(OttType.Netflix), ), BookmarkedContentItemModel( @@ -253,6 +264,8 @@ private object SavedContentPreviewData { title = "해리포터와 불의잔", year = 2005, imageUrl = "", + bookmarkCount = 51, + isBookmarked = true, getOttSimpleList = listOf(OttType.Netflix), ), BookmarkedContentItemModel( @@ -260,6 +273,8 @@ private object SavedContentPreviewData { title = "해리포터와 불의잔", year = 2005, imageUrl = "", + bookmarkCount = 4, + isBookmarked = false, getOttSimpleList = listOf(OttType.Netflix), ), BookmarkedContentItemModel( @@ -267,6 +282,8 @@ private object SavedContentPreviewData { title = "해리포터와 불의잔", year = 2005, imageUrl = "", + bookmarkCount = 36, + isBookmarked = true, getOttSimpleList = listOf(OttType.Netflix), ), ) @@ -279,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 = {}, @@ -328,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 52813265..f709b5b1 100644 --- a/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt +++ b/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt @@ -1,25 +1,38 @@ 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.domain.repository.ContentRepository +import com.flint.core.navigation.Route +import com.flint.data.dto.base.ErrorResponseDto +import com.flint.domain.repository.BookmarkRepository +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.collections.immutable.toPersistentList 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.decodeFromString +import kotlinx.serialization.json.Json +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() { + private val userId: String? = savedStateHandle.toRoute().userId + private val _uiState = MutableStateFlow(SavedContentUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -29,11 +42,13 @@ 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() + userRepository.getUserBookmarkedContents(userId) .onSuccess { list -> _uiState.update { it.copy( @@ -63,20 +78,77 @@ class SavedContentViewModel @Inject constructor( } - // 북마크 토글 (저장 취소) - // 저장된 작품이 MIN_REQUIRED_COUNT(5)개일 때는 취소를 막고 안내 모달을 노출한다. + // 북마크 토글 + // - 내 프로필: 북마크 취소 시 목록에서 제거 (MIN_REQUIRED_COUNT 제한 있음) + // - 타 유저 프로필: isBookmarked 필드만 토글 (목록은 상대방 저장 목록이므로 제거 X) fun toggleBookmark(contentId: String) { - val currentCount = uiState.value.totalCount - if (currentCount <= MIN_REQUIRED_COUNT) { - _uiState.update { it.copy(showBookmarkRestrictionModal = true) } - return + viewModelScope.launch { + Timber.d("toggleBookmark: contentId=$contentId, userId=$userId") + bookmarkRepository.toggleContentBookmark(contentId) + .onSuccess { isBookmarked -> + Timber.d("toggleBookmark success: isBookmarked=$isBookmarked") + 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 = maxOf(currentList.totalCount - 1, 0), + ) + } + 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)) + } + } + } + .onFailure { throwable -> + if (throwable.isContentMinLimitError()) { + _uiState.update { it.copy(showBookmarkRestrictionModal = true) } + } else { + Timber.e(throwable, "toggleBookmark failed: contentId=$contentId") + } + } } - // TODO: 북마크 토글 API 연동 - Timber.d("toggleBookmark: $contentId") } // 저장 취소 제한 안내 모달 닫기 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" + } } 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..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 @@ -42,6 +48,8 @@ fun ProfileKeywordSection( nickname: String, keywordList: KeywordListModel, isMyProfile: Boolean, + isRecalculatable: Boolean, + isRecalculating: Boolean = false, showInfoModal: Boolean, onInfoClick: () -> Unit, onRefreshClick: () -> Unit, @@ -87,7 +95,11 @@ fun ProfileKeywordSection( ) } if (isMyProfile) { - ProfileRefreshButton(onRefreshClick = onRefreshClick) + ProfileRefreshButton( + onRefreshClick = onRefreshClick, + isEnabled = isRecalculatable, + isRecalculating = isRecalculating, + ) } } Spacer(Modifier.height(32.dp)) @@ -117,21 +129,36 @@ fun ProfileKeywordSection( @Composable 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 = onRefreshClick, - ), + modifier = modifier.noRippleClickable( + onClick = { if (isEnabled && !isRecalculating) onRefreshClick() }, + ), ) { Icon( imageVector = ImageVector.vectorResource(R.drawable.ic_refresh), contentDescription = null, - tint = FlintTheme.colors.secondary400, + tint = iconTint, + modifier = Modifier.graphicsLayer { + rotationZ = if (isRecalculating) rotation else 0f + }, ) Text( text = "업데이트", @@ -237,6 +264,7 @@ private fun ProfileKeywordSectionPreview() { keywordList = KeywordListModel.FakeList3, modifier = Modifier.fillMaxSize(), isMyProfile = true, + isRecalculatable = true, showInfoModal = false, onInfoClick = {}, onRefreshClick = {}, 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 c125d4a9..a3b4eee4 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, navigateToSetting: () -> Unit = {}, ) { @@ -46,7 +46,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/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 = 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 { /** 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..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,14 +8,18 @@ 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(paddingValues: PaddingValues) { +fun NavGraphBuilder.savedContentListNavGraph( + paddingValues: PaddingValues, + navigateUp: () -> Unit, +) { composable { SavedContentListRoute( paddingValues = paddingValues, + navigateUp = navigateUp, ) } }