diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4995c1ca6..199855442 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -74,6 +74,9 @@ android { } } +// 룸 디비 제거 +// 좋아요 기능 좀더 생각해보기 + dependencies { // 프로젝트 의존성 implementation(projects.core.resource) @@ -83,7 +86,6 @@ dependencies { implementation(projects.core.authKakao) implementation(projects.core.network) implementation(projects.core.datastore) - implementation(projects.core.database) implementation(projects.data.account) implementation(projects.data.library) diff --git a/app/src/main/java/com/into/websoso/data/repository/FeedRepository.kt b/app/src/main/java/com/into/websoso/data/repository/FeedRepository.kt index 69e1c89fb..c06b33429 100644 --- a/app/src/main/java/com/into/websoso/data/repository/FeedRepository.kt +++ b/app/src/main/java/com/into/websoso/data/repository/FeedRepository.kt @@ -2,7 +2,6 @@ package com.into.websoso.data.repository import android.net.Uri import com.into.websoso.core.common.util.ImageCompressor -import com.into.websoso.data.library.datasource.LibraryLocalDataSource import com.into.websoso.data.mapper.MultiPartMapper import com.into.websoso.data.mapper.toData import com.into.websoso.data.model.CommentsEntity @@ -20,201 +19,162 @@ import javax.inject.Singleton @Singleton class FeedRepository - @Inject - constructor( - private val feedApi: FeedApi, - private val multiPartMapper: MultiPartMapper, - private val imageDownloader: ImageDownloader, - private val imageCompressor: ImageCompressor, - private val libraryLocalDataSource: LibraryLocalDataSource, - ) { - private val _cachedFeeds: MutableList = mutableListOf() - val cachedFeeds: List get() = _cachedFeeds.toList() - - fun clearCachedFeeds() { - if (cachedFeeds.isNotEmpty()) _cachedFeeds.clear() - } - - suspend fun fetchFeeds( - category: String, - lastFeedId: Long, - size: Int, - ): FeedsEntity = - feedApi - .getFeeds( - category = if (category == "all") null else category, - lastFeedId = lastFeedId, - size = size, - ).toData() - .also { _cachedFeeds.addAll(it.feeds) } - .copy(feeds = cachedFeeds) - - suspend fun saveFeed( - relevantCategories: List, - feedContent: String, - novelId: Long?, - isSpoiler: Boolean, - isPublic: Boolean, - images: List, - ) { - runCatching { - feedApi.postFeed( - feedRequestDto = multiPartMapper.formatToMultipart( - target = FeedRequestDto( - relevantCategories = relevantCategories, - feedContent = feedContent, - novelId = novelId, - isSpoiler = isSpoiler, - isPublic = isPublic, - ), - partName = PART_NAME_FEED, - fileName = "feed.json", - ), - images = images.map { multiPartMapper.formatToMultipart(it) }, - ) - }.onSuccess { - val novel = novelId?.let { id -> - libraryLocalDataSource.selectNovelByNovelId(id) - } - - if (novel != null) { - val updatedNovel = novel.copy(myFeeds = listOf(feedContent) + novel.myFeeds) - libraryLocalDataSource.insertNovel(updatedNovel) - } - } - } - - suspend fun saveEditedFeed( - feedId: Long, - relevantCategories: List, - editedFeed: String, - legacyFeed: String, - novelId: Long?, - isSpoiler: Boolean, - isPublic: Boolean, - images: List, - ) { - runCatching { - feedApi.putFeed( - feedId = feedId, - feedRequestDto = multiPartMapper.formatToMultipart( - target = FeedRequestDto( - relevantCategories = relevantCategories, - feedContent = editedFeed, - novelId = novelId, - isSpoiler = isSpoiler, - isPublic = isPublic, - ), - partName = "feed", - fileName = "feed.json", - ), - images = images.map { multiPartMapper.formatToMultipart(it) }, - ) - }.onSuccess { - val novel = novelId?.let { id -> - libraryLocalDataSource.selectNovelByNovelId(id) - } - - if (novel != null) { - val updatedNovel = novel.copy( - myFeeds = novel.myFeeds.map { currentFeed -> - if (currentFeed == legacyFeed) editedFeed else currentFeed - }, - ) - libraryLocalDataSource.insertNovel(updatedNovel) - } - } - } +@Inject +constructor( + private val feedApi: FeedApi, + private val multiPartMapper: MultiPartMapper, + private val imageDownloader: ImageDownloader, + private val imageCompressor: ImageCompressor, +) { + private val _cachedFeeds: MutableList = mutableListOf() + val cachedFeeds: List get() = _cachedFeeds.toList() + + fun clearCachedFeeds() { + if (cachedFeeds.isNotEmpty()) _cachedFeeds.clear() + } - suspend fun fetchFeed(feedId: Long): FeedDetailEntity = feedApi.getFeed(feedId).toData() + suspend fun fetchFeeds( + category: String, + lastFeedId: Long, + size: Int, + ): FeedsEntity = + feedApi + .getFeeds( + category = if (category == "all") null else category, + lastFeedId = lastFeedId, + size = size, + ).toData() + .also { _cachedFeeds.addAll(it.feeds) } + .copy(feeds = cachedFeeds) + + suspend fun saveFeed( + relevantCategories: List, + feedContent: String, + novelId: Long?, + isSpoiler: Boolean, + isPublic: Boolean, + images: List, + ) { + feedApi.postFeed( + feedRequestDto = multiPartMapper.formatToMultipart( + target = FeedRequestDto( + relevantCategories = relevantCategories, + feedContent = feedContent, + novelId = novelId, + isSpoiler = isSpoiler, + isPublic = isPublic, + ), + partName = PART_NAME_FEED, + fileName = "feed.json", + ), + images = images.map { multiPartMapper.formatToMultipart(it) }, + ) + } - suspend fun fetchPopularFeeds(): PopularFeedsEntity = feedApi.getPopularFeeds().toData() + suspend fun saveEditedFeed( + feedId: Long, + relevantCategories: List, + editedFeed: String, + novelId: Long?, + isSpoiler: Boolean, + isPublic: Boolean, + images: List, + ) { + feedApi.putFeed( + feedId = feedId, + feedRequestDto = multiPartMapper.formatToMultipart( + target = FeedRequestDto( + relevantCategories = relevantCategories, + feedContent = editedFeed, + novelId = novelId, + isSpoiler = isSpoiler, + isPublic = isPublic, + ), + partName = "feed", + fileName = "feed.json", + ), + images = images.map { multiPartMapper.formatToMultipart(it) }, + ) + } - suspend fun fetchUserInterestFeeds(): UserInterestFeedsEntity = feedApi.getUserInterestFeeds().toData() + suspend fun fetchFeed(feedId: Long): FeedDetailEntity = feedApi.getFeed(feedId).toData() - suspend fun saveRemovedFeed( - feedId: Long, - novelId: Long?, - content: String, - ) { - runCatching { - feedApi.deleteFeed(feedId) - }.onSuccess { - _cachedFeeds.removeIf { it.id == feedId } + suspend fun fetchPopularFeeds(): PopularFeedsEntity = feedApi.getPopularFeeds().toData() - val novel = novelId?.let { id -> - libraryLocalDataSource.selectNovelByNovelId(id) - } + suspend fun fetchUserInterestFeeds(): UserInterestFeedsEntity = + feedApi.getUserInterestFeeds().toData() - if (novel != null) { - val updatedNovel = novel.copy(myFeeds = novel.myFeeds.filterNot { it == content }) - libraryLocalDataSource.insertNovel(updatedNovel) - } - } + suspend fun saveRemovedFeed(feedId: Long) { + runCatching { + feedApi.deleteFeed(feedId) + }.onSuccess { + _cachedFeeds.removeIf { it.id == feedId } } + } - suspend fun saveSpoilerFeed(feedId: Long) { - feedApi.postSpoilerFeed(feedId) - } + suspend fun saveSpoilerFeed(feedId: Long) { + feedApi.postSpoilerFeed(feedId) + } - suspend fun saveImpertinenceFeed(feedId: Long) { - feedApi.postImpertinenceFeed(feedId) - } + suspend fun saveImpertinenceFeed(feedId: Long) { + feedApi.postImpertinenceFeed(feedId) + } - suspend fun saveLike( - isLikedOfLikedFeed: Boolean, - selectedFeedId: Long, - ) { - when (isLikedOfLikedFeed) { - true -> feedApi.deleteLikes(selectedFeedId) - false -> feedApi.postLikes(selectedFeedId) - } + suspend fun saveLike( + isLikedOfLikedFeed: Boolean, + selectedFeedId: Long, + ) { + when (isLikedOfLikedFeed) { + true -> feedApi.deleteLikes(selectedFeedId) + false -> feedApi.postLikes(selectedFeedId) } + } - suspend fun fetchComments(feedId: Long): CommentsEntity = feedApi.getComments(feedId).toData() + suspend fun fetchComments(feedId: Long): CommentsEntity = feedApi.getComments(feedId).toData() - suspend fun saveComment( - feedId: Long, - comment: String, - ) { - feedApi.postComment(feedId, CommentRequestDto(comment)) - } + suspend fun saveComment( + feedId: Long, + comment: String, + ) { + feedApi.postComment(feedId, CommentRequestDto(comment)) + } - suspend fun saveModifiedComment( - feedId: Long, - commentId: Long, - comment: String, - ) { - feedApi.putComment(feedId, commentId, CommentRequestDto(comment)) - } + suspend fun saveModifiedComment( + feedId: Long, + commentId: Long, + comment: String, + ) { + feedApi.putComment(feedId, commentId, CommentRequestDto(comment)) + } - suspend fun deleteComment( - feedId: Long, - commentId: Long, - ) { - feedApi.deleteComment(feedId, commentId) - } + suspend fun deleteComment( + feedId: Long, + commentId: Long, + ) { + feedApi.deleteComment(feedId, commentId) + } - suspend fun saveSpoilerComment( - feedId: Long, - commentId: Long, - ) { - feedApi.postSpoilerComment(feedId, commentId) - } + suspend fun saveSpoilerComment( + feedId: Long, + commentId: Long, + ) { + feedApi.postSpoilerComment(feedId, commentId) + } - suspend fun saveImpertinenceComment( - feedId: Long, - commentId: Long, - ) { - feedApi.postImpertinenceComment(feedId, commentId) - } + suspend fun saveImpertinenceComment( + feedId: Long, + commentId: Long, + ) { + feedApi.postImpertinenceComment(feedId, commentId) + } - suspend fun downloadImage(imageUrl: String): Result = imageDownloader.formatImageToUri(imageUrl) + suspend fun downloadImage(imageUrl: String): Result = + imageDownloader.formatImageToUri(imageUrl) - suspend fun compressImages(imageUris: List): List = imageCompressor.compressUris(imageUris) + suspend fun compressImages(imageUris: List): List = + imageCompressor.compressUris(imageUris) - companion object { - private const val PART_NAME_FEED: String = "feed" - private const val FILE_NAME_FEED: String = "feed.json" - } + companion object { + private const val PART_NAME_FEED: String = "feed" } +} diff --git a/app/src/main/java/com/into/websoso/data/repository/NovelRepository.kt b/app/src/main/java/com/into/websoso/data/repository/NovelRepository.kt index d207fc6f5..449d7b3f4 100644 --- a/app/src/main/java/com/into/websoso/data/repository/NovelRepository.kt +++ b/app/src/main/java/com/into/websoso/data/repository/NovelRepository.kt @@ -1,6 +1,5 @@ package com.into.websoso.data.repository -import com.into.websoso.data.library.datasource.LibraryLocalDataSource import com.into.websoso.data.mapper.toData import com.into.websoso.data.model.ExploreResultEntity import com.into.websoso.data.model.ExploreResultEntity.NovelEntity @@ -14,110 +13,105 @@ import com.into.websoso.data.remote.api.NovelApi import javax.inject.Inject class NovelRepository - @Inject - constructor( - private val novelApi: NovelApi, - private val libraryLocalDataSource: LibraryLocalDataSource, - ) { - var cachedNormalExploreIsLoadable: Boolean = true - private set - - private val _cachedNormalExploreResult: MutableList = mutableListOf() - val cachedNormalExploreResult: List get() = _cachedNormalExploreResult.toList() - - var cachedDetailExploreIsLoadable: Boolean = true - private set - - private val _cachedDetailExploreResult: MutableList = mutableListOf() - val cachedDetailExploreResult: List get() = _cachedDetailExploreResult.toList() - - suspend fun getNovelDetail(novelId: Long): NovelDetailEntity = novelApi.getNovelDetail(novelId).toData() - - suspend fun saveUserInterest( - novelId: Long, - isInterest: Boolean, - ) { - runCatching { - when (isInterest) { - true -> novelApi.postUserInterest(novelId) - false -> novelApi.deleteUserInterest(novelId) - } - }.onSuccess { - libraryLocalDataSource.selectNovelByNovelId(novelId)?.let { novel -> - libraryLocalDataSource.insertNovel(novel.copy(isInterest = isInterest)) - } - } - } +@Inject +constructor( + private val novelApi: NovelApi, +) { + var cachedNormalExploreIsLoadable: Boolean = true + private set - suspend fun fetchNovelInfo(novelId: Long): NovelInfoEntity = novelApi.getNovelInfo(novelId).toData() - - suspend fun fetchSosoPicks(): SosoPickEntity = novelApi.getSosoPicks().toData() - - suspend fun fetchNormalExploreResult( - searchWord: String, - page: Int, - size: Int, - ): ExploreResultEntity { - val result = - novelApi.getNormalExploreResult(searchWord = searchWord, page = page, size = size) - - return result - .toData() - .also { - _cachedNormalExploreResult.addAll(it.novels) - cachedNormalExploreIsLoadable = result.isLoadable - }.copy( - isLoadable = cachedNormalExploreIsLoadable, - novels = cachedNormalExploreResult, - ) - } + private val _cachedNormalExploreResult: MutableList = mutableListOf() + val cachedNormalExploreResult: List get() = _cachedNormalExploreResult.toList() + + var cachedDetailExploreIsLoadable: Boolean = true + private set - fun clearCachedNormalExploreResult() { - _cachedNormalExploreResult.clear() - cachedNormalExploreIsLoadable = true + private val _cachedDetailExploreResult: MutableList = mutableListOf() + val cachedDetailExploreResult: List get() = _cachedDetailExploreResult.toList() + + suspend fun getNovelDetail(novelId: Long): NovelDetailEntity = + novelApi.getNovelDetail(novelId).toData() + + suspend fun saveUserInterest( + novelId: Long, + isInterest: Boolean, + ) { + when (isInterest) { + true -> novelApi.postUserInterest(novelId) + false -> novelApi.deleteUserInterest(novelId) } + } - suspend fun fetchPopularNovels(): PopularNovelsEntity = novelApi.getPopularNovels().toData() - - suspend fun fetchRecommendedNovelsByUserTaste(): RecommendedNovelsByUserTasteEntity = - novelApi.getRecommendedNovelsByUserTaste().toData() - - suspend fun fetchFilteredNovelResult( - genres: List?, - isCompleted: Boolean?, - novelRating: Float?, - keywordIds: List?, - page: Int, - size: Int, - ): ExploreResultEntity { - val result = novelApi.getFilteredNovelResult( - genres = genres, - isCompleted = isCompleted, - novelRating = novelRating, - keywordIds = keywordIds, - page = page, - size = size, + suspend fun fetchNovelInfo(novelId: Long): NovelInfoEntity = + novelApi.getNovelInfo(novelId).toData() + + suspend fun fetchSosoPicks(): SosoPickEntity = novelApi.getSosoPicks().toData() + + suspend fun fetchNormalExploreResult( + searchWord: String, + page: Int, + size: Int, + ): ExploreResultEntity { + val result = + novelApi.getNormalExploreResult(searchWord = searchWord, page = page, size = size) + + return result + .toData() + .also { + _cachedNormalExploreResult.addAll(it.novels) + cachedNormalExploreIsLoadable = result.isLoadable + }.copy( + isLoadable = cachedNormalExploreIsLoadable, + novels = cachedNormalExploreResult, ) + } - return result - .toData() - .also { - _cachedDetailExploreResult.addAll(it.novels) - cachedDetailExploreIsLoadable = result.isLoadable - }.copy( - isLoadable = cachedDetailExploreIsLoadable, - novels = cachedDetailExploreResult, - ) - } + fun clearCachedNormalExploreResult() { + _cachedNormalExploreResult.clear() + cachedNormalExploreIsLoadable = true + } - fun clearCachedDetailExploreResult() { - _cachedDetailExploreResult.clear() - cachedDetailExploreIsLoadable = true - } + suspend fun fetchPopularNovels(): PopularNovelsEntity = novelApi.getPopularNovels().toData() + + suspend fun fetchRecommendedNovelsByUserTaste(): RecommendedNovelsByUserTasteEntity = + novelApi.getRecommendedNovelsByUserTaste().toData() + + suspend fun fetchFilteredNovelResult( + genres: List?, + isCompleted: Boolean?, + novelRating: Float?, + keywordIds: List?, + page: Int, + size: Int, + ): ExploreResultEntity { + val result = novelApi.getFilteredNovelResult( + genres = genres, + isCompleted = isCompleted, + novelRating = novelRating, + keywordIds = keywordIds, + page = page, + size = size, + ) + + return result + .toData() + .also { + _cachedDetailExploreResult.addAll(it.novels) + cachedDetailExploreIsLoadable = result.isLoadable + }.copy( + isLoadable = cachedDetailExploreIsLoadable, + novels = cachedDetailExploreResult, + ) + } - suspend fun fetchNovelFeeds( - novelId: Long, - lastFeedId: Long, - size: Int, - ): NovelFeedsEntity = novelApi.getNovelFeeds(novelId, lastFeedId, size).toData() + fun clearCachedDetailExploreResult() { + _cachedDetailExploreResult.clear() + cachedDetailExploreIsLoadable = true } + + suspend fun fetchNovelFeeds( + novelId: Long, + lastFeedId: Long, + size: Int, + ): NovelFeedsEntity = novelApi.getNovelFeeds(novelId, lastFeedId, size).toData() +} diff --git a/app/src/main/java/com/into/websoso/data/repository/UserNovelRepository.kt b/app/src/main/java/com/into/websoso/data/repository/UserNovelRepository.kt index 05629d672..0bf82f463 100644 --- a/app/src/main/java/com/into/websoso/data/repository/UserNovelRepository.kt +++ b/app/src/main/java/com/into/websoso/data/repository/UserNovelRepository.kt @@ -1,56 +1,30 @@ package com.into.websoso.data.repository -import com.into.websoso.data.library.datasource.LibraryLocalDataSource -import com.into.websoso.data.library.model.NovelEntity import com.into.websoso.data.mapper.toData import com.into.websoso.data.model.NovelRatingEntity import com.into.websoso.data.remote.api.UserNovelApi import javax.inject.Inject class UserNovelRepository - @Inject - constructor( - private val userNovelApi: UserNovelApi, - private val libraryLocalDataSource: LibraryLocalDataSource, - ) { - suspend fun deleteUserNovel(novelId: Long) { - runCatching { userNovelApi.deleteUserNovel(novelId) } - .onSuccess { libraryLocalDataSource.deleteNovel(novelId) } - } - - suspend fun fetchNovelRating(novelId: Long): NovelRatingEntity = userNovelApi.fetchNovelRating(novelId).toData() +@Inject +constructor( + private val userNovelApi: UserNovelApi, +) { + suspend fun deleteUserNovel(novelId: Long) { + userNovelApi.deleteUserNovel(novelId) + } - suspend fun saveNovelRating( - feeds: List, - novelRatingEntity: NovelRatingEntity, - isInterested: Boolean?, - isAlreadyRated: Boolean, - ) { - runCatching { - if (isAlreadyRated) { - userNovelApi.putNovelRating(novelRatingEntity.novelId!!, novelRatingEntity.toData()) - } else { - userNovelApi.postNovelRating(novelRatingEntity.toData()) - } - }.onSuccess { - val updatedNovel = NovelEntity( - userNovelId = novelRatingEntity.userNovelId - ?: libraryLocalDataSource.selectAllNovelsCount().toLong(), - novelId = novelRatingEntity.novelId!!, - title = novelRatingEntity.novelTitle.orEmpty(), - novelImage = novelRatingEntity.novelImage.orEmpty(), - novelRating = novelRatingEntity.novelRating, - readStatus = novelRatingEntity.readStatus.orEmpty(), - isInterest = isInterested ?: false, - userNovelRating = novelRatingEntity.userNovelRating, - attractivePoints = novelRatingEntity.charmPoints, - startDate = novelRatingEntity.startDate.orEmpty(), - endDate = novelRatingEntity.endDate.orEmpty(), - keywords = novelRatingEntity.userKeywords.map { it.keywordName }, - myFeeds = feeds, - ) + suspend fun fetchNovelRating(novelId: Long): NovelRatingEntity = + userNovelApi.fetchNovelRating(novelId).toData() - libraryLocalDataSource.insertNovel(updatedNovel) - } + suspend fun saveNovelRating( + novelRatingEntity: NovelRatingEntity, + isAlreadyRated: Boolean, + ) { + if (isAlreadyRated) { + userNovelApi.putNovelRating(novelRatingEntity.novelId!!, novelRatingEntity.toData()) + } else { + userNovelApi.postNovelRating(novelRatingEntity.toData()) } } +} diff --git a/app/src/main/java/com/into/websoso/ui/accountInfo/AccountInfoViewModel.kt b/app/src/main/java/com/into/websoso/ui/accountInfo/AccountInfoViewModel.kt index 9c6fb8363..9d564bc1f 100644 --- a/app/src/main/java/com/into/websoso/ui/accountInfo/AccountInfoViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/accountInfo/AccountInfoViewModel.kt @@ -21,59 +21,57 @@ import javax.inject.Inject @HiltViewModel class AccountInfoViewModel - @Inject - constructor( - private val userRepository: UserRepository, - private val pushMessageRepository: PushMessageRepository, - private val accountRepository: AccountRepository, - private val libraryRepository: MyLibraryRepository, - private val filterRepository: MyLibraryFilterRepository, - ) : ViewModel() { - private val _userEmail: MutableStateFlow = MutableStateFlow("") - val userEmail: StateFlow get() = _userEmail.asStateFlow() +@Inject +constructor( + private val userRepository: UserRepository, + private val pushMessageRepository: PushMessageRepository, + private val accountRepository: AccountRepository, + private val libraryRepository: MyLibraryRepository, + private val filterRepository: MyLibraryFilterRepository, +) : ViewModel() { + private val _userEmail: MutableStateFlow = MutableStateFlow("") + val userEmail: StateFlow get() = _userEmail.asStateFlow() - private val _uiEffect = Channel(Channel.BUFFERED) - val uiEffect: Flow get() = _uiEffect.receiveAsFlow() + private val _uiEffect = Channel(Channel.BUFFERED) + val uiEffect: Flow get() = _uiEffect.receiveAsFlow() - init { - updateUserEmail() - } + init { + updateUserEmail() + } - private fun updateUserEmail() { - viewModelScope.launch { - runCatching { - userRepository.fetchUserInfoDetail() - }.onSuccess { userInfo -> - _userEmail.update { userInfo.email } - } + private fun updateUserEmail() { + viewModelScope.launch { + runCatching { + userRepository.fetchUserInfoDetail() + }.onSuccess { userInfo -> + _userEmail.update { userInfo.email } } } + } - fun signOut() { - viewModelScope.launch { - val userDeviceIdentifier = userRepository.fetchUserDeviceIdentifier() + fun signOut() { + viewModelScope.launch { + val userDeviceIdentifier = userRepository.fetchUserDeviceIdentifier() - accountRepository - .deleteTokens(userDeviceIdentifier) - .onSuccess { - userRepository.removeTermsAgreementChecked() - pushMessageRepository.clearFCMToken() - libraryRepository.deleteAllNovels() - filterRepository.deleteLibraryFilter() - _uiEffect.send(NavigateToLogin) - }.onFailure { - _uiEffect.send(NavigateToLogin) - } - } + accountRepository + .deleteTokens(userDeviceIdentifier) + .onSuccess { + userRepository.removeTermsAgreementChecked() + pushMessageRepository.clearFCMToken() + filterRepository.deleteLibraryFilter() + _uiEffect.send(NavigateToLogin) + }.onFailure { + _uiEffect.send(NavigateToLogin) + } } + } - fun clearCache() { - viewModelScope.launch { - libraryRepository.deleteAllNovels() - filterRepository.deleteLibraryFilter() - } + fun clearCache() { + viewModelScope.launch { + filterRepository.deleteLibraryFilter() } } +} sealed interface UiEffect { data object NavigateToLogin : UiEffect diff --git a/app/src/main/java/com/into/websoso/ui/activityDetail/ActivityDetailViewModel.kt b/app/src/main/java/com/into/websoso/ui/activityDetail/ActivityDetailViewModel.kt index a079f574a..fad67ceea 100644 --- a/app/src/main/java/com/into/websoso/ui/activityDetail/ActivityDetailViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/activityDetail/ActivityDetailViewModel.kt @@ -17,207 +17,202 @@ import javax.inject.Inject @HiltViewModel class ActivityDetailViewModel - @Inject - constructor( - private val userRepository: UserRepository, - private val feedRepository: FeedRepository, - private val savedStateHandle: SavedStateHandle, - ) : ViewModel() { - private val _uiState = MutableLiveData() - val uiState: LiveData get() = _uiState - - private val likeState = MutableLiveData() - - private val size: Int = ACTIVITY_LOAD_SIZE - - var source: String? - get() = savedStateHandle["source"] - set(value) { - savedStateHandle["source"] = value - } +@Inject +constructor( + private val userRepository: UserRepository, + private val feedRepository: FeedRepository, + private val savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val _uiState = MutableLiveData() + val uiState: LiveData get() = _uiState + + private val likeState = MutableLiveData() + + private val size: Int = ACTIVITY_LOAD_SIZE + + var source: String? + get() = savedStateHandle["source"] + set(value) { + savedStateHandle["source"] = value + } - var userId: Long - get() = savedStateHandle["userId"] ?: DEFAULT_USER_ID - set(value) { - savedStateHandle["userId"] = value - } + var userId: Long + get() = savedStateHandle["userId"] ?: DEFAULT_USER_ID + set(value) { + savedStateHandle["userId"] = value + } + + init { + _uiState.value = ActivityDetailUiState() + } + + fun updateRefreshedActivities() { + _uiState.value = uiState.value?.copy(lastFeedId = 0L) + updateUserActivities(userId) + } - init { - _uiState.value = ActivityDetailUiState() + fun updateUserActivities(userId: Long) { + this.userId = userId + if (source == SOURCE_MY_ACTIVITY) { + updateMyActivities() + } else { + updateOtherUserActivities(userId) } + } - fun updateRefreshedActivities() { - _uiState.value = uiState.value?.copy(lastFeedId = 0L) - updateUserActivities(userId) + private fun updateMyActivities() { + _uiState.value = uiState.value?.copy(isLoading = true) + viewModelScope.launch { + runCatching { + userRepository.fetchMyActivities( + uiState.value?.lastFeedId ?: 0L, + size, + ) + }.onSuccess { response -> + _uiState.value = uiState.value?.copy( + isLoading = false, + activities = response.feeds.map { it.toUi() }, + lastFeedId = response.feeds + .lastOrNull() + ?.feedId + ?.toLong() ?: 0L, + isError = false, + ) + }.onFailure { exception -> + _uiState.value = uiState.value?.copy( + isLoading = false, + isError = true, + ) + } } + } - fun updateUserActivities(userId: Long) { - this.userId = userId - if (source == SOURCE_MY_ACTIVITY) { - updateMyActivities() - } else { - updateOtherUserActivities(userId) + private fun updateOtherUserActivities(userId: Long) { + _uiState.value = uiState.value?.copy(isLoading = true) + viewModelScope.launch { + runCatching { + userRepository.fetchUserFeeds( + userId = userId, + lastFeedId = uiState.value?.lastFeedId ?: 0L, + size = size, + ) + }.onSuccess { response -> + _uiState.value = uiState.value?.copy( + isLoading = false, + activities = response.feeds.map { it.toUi() }, + lastFeedId = response.feeds + .lastOrNull() + ?.feedId + ?.toLong() ?: 0L, + isError = false, + ) + }.onFailure { exception -> + _uiState.value = uiState.value?.copy( + isLoading = false, + isError = true, + ) } } + } - private fun updateMyActivities() { - _uiState.value = uiState.value?.copy(isLoading = true) - viewModelScope.launch { - runCatching { - userRepository.fetchMyActivities( - uiState.value?.lastFeedId ?: 0L, - size, - ) - }.onSuccess { response -> - _uiState.value = uiState.value?.copy( - isLoading = false, - activities = response.feeds.map { it.toUi() }, - lastFeedId = response.feeds - .lastOrNull() - ?.feedId - ?.toLong() ?: 0L, - isError = false, - ) - }.onFailure { exception -> - _uiState.value = uiState.value?.copy( - isLoading = false, - isError = true, - ) + fun updateActivityLike( + isLiked: Boolean, + feedId: Long, + currentLikeCount: Int, + ) { + viewModelScope.launch { + runCatching { + if (isLiked) { + feedRepository.saveLike(false, feedId) + } else { + feedRepository.saveLike(true, feedId) } + }.onSuccess { + val newLikeCount = if (isLiked) currentLikeCount - 1 else currentLikeCount + 1 + likeState.value = ActivityLikeState(feedId, !isLiked, newLikeCount) + updateLikeStateInUi(feedId, !isLiked, newLikeCount) + }.onFailure { } } + } - private fun updateOtherUserActivities(userId: Long) { - _uiState.value = uiState.value?.copy(isLoading = true) - viewModelScope.launch { - runCatching { - userRepository.fetchUserFeeds( - userId = userId, - lastFeedId = uiState.value?.lastFeedId ?: 0L, - size = size, - ) - }.onSuccess { response -> - _uiState.value = uiState.value?.copy( - isLoading = false, - activities = response.feeds.map { it.toUi() }, - lastFeedId = response.feeds - .lastOrNull() - ?.feedId - ?.toLong() ?: 0L, - isError = false, - ) - }.onFailure { exception -> - _uiState.value = uiState.value?.copy( - isLoading = false, - isError = true, + private fun updateLikeStateInUi( + feedId: Long, + isLiked: Boolean, + likeCount: Int, + ) { + _uiState.value = uiState.value?.copy( + activities = uiState.value?.activities?.map { activity -> + if (activity.feedId == feedId) { + activity.copy( + isLiked = isLiked, + likeCount = likeCount, ) + } else { + activity } + } ?: emptyList(), + ) + } + + fun updateRemovedFeed(feedId: Long) { + viewModelScope.launch { + _uiState.value = uiState.value?.copy(isLoading = true) + runCatching { + feedRepository.saveRemovedFeed(feedId = feedId) + }.onSuccess { + _uiState.value = uiState.value?.copy( + isLoading = false, + activities = uiState.value?.activities?.filter { it.feedId != feedId } + ?: emptyList(), + ) + }.onFailure { + _uiState.value = uiState.value?.copy( + isLoading = false, + isError = true, + ) } } + } - fun updateActivityLike( - isLiked: Boolean, - feedId: Long, - currentLikeCount: Int, - ) { + fun updateReportedSpoilerFeed(feedId: Long) { + _uiState.value?.let { feedUiState -> viewModelScope.launch { + _uiState.value = feedUiState.copy(isLoading = true) runCatching { - if (isLiked) { - feedRepository.saveLike(false, feedId) - } else { - feedRepository.saveLike(true, feedId) - } + feedRepository.saveSpoilerFeed(feedId) }.onSuccess { - val newLikeCount = if (isLiked) currentLikeCount - 1 else currentLikeCount + 1 - likeState.value = ActivityLikeState(feedId, !isLiked, newLikeCount) - updateLikeStateInUi(feedId, !isLiked, newLikeCount) + _uiState.value = feedUiState.copy(isLoading = false) }.onFailure { + _uiState.value = feedUiState.copy( + isLoading = false, + isError = true, + ) } } } + } - private fun updateLikeStateInUi( - feedId: Long, - isLiked: Boolean, - likeCount: Int, - ) { - _uiState.value = uiState.value?.copy( - activities = uiState.value?.activities?.map { activity -> - if (activity.feedId == feedId) { - activity.copy( - isLiked = isLiked, - likeCount = likeCount, - ) - } else { - activity - } - } ?: emptyList(), - ) - } - - fun updateRemovedFeed(feedId: Long) { + fun updateReportedImpertinenceFeed(feedId: Long) { + _uiState.value?.let { feedUiState -> viewModelScope.launch { - _uiState.value = uiState.value?.copy(isLoading = true) - val removedFeed = uiState.value?.activities?.find { it.feedId == feedId } + _uiState.value = feedUiState.copy(isLoading = true) runCatching { - feedRepository.saveRemovedFeed( - feedId = feedId, - novelId = removedFeed?.novelId, - content = removedFeed?.feedContent.orEmpty(), - ) + feedRepository.saveImpertinenceFeed(feedId) }.onSuccess { - _uiState.value = uiState.value?.copy( - isLoading = false, - activities = uiState.value?.activities?.filter { it.feedId != feedId } - ?: emptyList(), - ) + _uiState.value = feedUiState.copy(isLoading = false) }.onFailure { - _uiState.value = uiState.value?.copy( + _uiState.value = feedUiState.copy( isLoading = false, isError = true, ) } } } + } - fun updateReportedSpoilerFeed(feedId: Long) { - _uiState.value?.let { feedUiState -> - viewModelScope.launch { - _uiState.value = feedUiState.copy(isLoading = true) - runCatching { - feedRepository.saveSpoilerFeed(feedId) - }.onSuccess { - _uiState.value = feedUiState.copy(isLoading = false) - }.onFailure { - _uiState.value = feedUiState.copy( - isLoading = false, - isError = true, - ) - } - } - } - } - - fun updateReportedImpertinenceFeed(feedId: Long) { - _uiState.value?.let { feedUiState -> - viewModelScope.launch { - _uiState.value = feedUiState.copy(isLoading = true) - runCatching { - feedRepository.saveImpertinenceFeed(feedId) - }.onSuccess { - _uiState.value = feedUiState.copy(isLoading = false) - }.onFailure { - _uiState.value = feedUiState.copy( - isLoading = false, - isError = true, - ) - } - } - } - } - - companion object { - const val ACTIVITY_LOAD_SIZE = 100 - const val DEFAULT_USER_ID = -1L - } + companion object { + const val ACTIVITY_LOAD_SIZE = 100 + const val DEFAULT_USER_ID = -1L } +} diff --git a/app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedActivity.kt b/app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedActivity.kt index 941f8fd8e..5e83e3a7f 100644 --- a/app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedActivity.kt @@ -142,7 +142,6 @@ class CreateFeedActivity : BaseActivity(activity_crea editFeedModel.feedCategory.isEmpty() -> createFeedViewModel.createFeed() else -> createFeedViewModel.editFeed( feedId = editFeedModel.feedId, - legacyFeed = editFeedModel.feedContent, ) } diff --git a/app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt b/app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt index e197e74ae..71eb45b5a 100644 --- a/app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt @@ -26,301 +26,297 @@ import javax.inject.Inject @HiltViewModel class CreateFeedViewModel - @Inject - constructor( - savedStateHandle: SavedStateHandle, - private val getSearchedNovelsUseCase: GetSearchedNovelsUseCase, - private val feedRepository: FeedRepository, - ) : ViewModel() { - private val _searchNovelUiState: MutableLiveData = - MutableLiveData(SearchNovelUiState()) - val searchNovelUiState: LiveData get() = _searchNovelUiState - private val _categories: MutableList = mutableListOf() - val categories: List get() = _categories.toList() - private val _selectedNovelTitle: MutableLiveData = MutableLiveData() - val selectedNovelTitle: LiveData get() = _selectedNovelTitle - private val _attachedImages = MutableStateFlow>(emptyList()) - val attachedImages: StateFlow> get() = _attachedImages - private val _exceedingImageCountEvent: MutableSharedFlow = MutableSharedFlow() - val exceedingImageCountEvent: SharedFlow get() = _exceedingImageCountEvent.asSharedFlow() - private val _updateFeedSuccessEvent: MutableSharedFlow = MutableSharedFlow() - val updateFeedSuccessEvent: SharedFlow get() = _updateFeedSuccessEvent.asSharedFlow() - private val _isUploading: MutableLiveData = MutableLiveData(false) - val isUploading: LiveData get() = _isUploading - val isLoading: MutableLiveData = MutableLiveData(false) - val isActivated: MediatorLiveData = MediatorLiveData(false) - val isSpoiled: MutableLiveData = MutableLiveData(false) - val isPublic: MutableLiveData = MutableLiveData(true) - val content: MutableLiveData = MutableLiveData("") - private var novelId: Long? = null - private var searchedText = "" +@Inject +constructor( + savedStateHandle: SavedStateHandle, + private val getSearchedNovelsUseCase: GetSearchedNovelsUseCase, + private val feedRepository: FeedRepository, +) : ViewModel() { + private val _searchNovelUiState: MutableLiveData = + MutableLiveData(SearchNovelUiState()) + val searchNovelUiState: LiveData get() = _searchNovelUiState + private val _categories: MutableList = mutableListOf() + val categories: List get() = _categories.toList() + private val _selectedNovelTitle: MutableLiveData = MutableLiveData() + val selectedNovelTitle: LiveData get() = _selectedNovelTitle + private val _attachedImages = MutableStateFlow>(emptyList()) + val attachedImages: StateFlow> get() = _attachedImages + private val _exceedingImageCountEvent: MutableSharedFlow = MutableSharedFlow() + val exceedingImageCountEvent: SharedFlow get() = _exceedingImageCountEvent.asSharedFlow() + private val _updateFeedSuccessEvent: MutableSharedFlow = MutableSharedFlow() + val updateFeedSuccessEvent: SharedFlow get() = _updateFeedSuccessEvent.asSharedFlow() + private val _isUploading: MutableLiveData = MutableLiveData(false) + val isUploading: LiveData get() = _isUploading + val isLoading: MutableLiveData = MutableLiveData(false) + val isActivated: MediatorLiveData = MediatorLiveData(false) + val isSpoiled: MutableLiveData = MutableLiveData(false) + val isPublic: MutableLiveData = MutableLiveData(true) + val content: MutableLiveData = MutableLiveData("") + private var novelId: Long? = null + private var searchedText = "" - init { - fun createCategories(feedCategory: List? = null): List = - CreateFeedCategory.entries.map { category -> - CreatedFeedCategoryModel( - category = category, - isSelected = feedCategory?.contains(category.krTitle) == true, - ) - } + init { + fun createCategories(feedCategory: List? = null): List = + CreateFeedCategory.entries.map { category -> + CreatedFeedCategoryModel( + category = category, + isSelected = feedCategory?.contains(category.krTitle) == true, + ) + } - savedStateHandle.get("FEED")?.let { feed -> - novelId = feed.novelId - _selectedNovelTitle.value = feed.novelTitle.orEmpty() - content.value = feed.feedContent - isSpoiled.value = feed.isSpoiler - isPublic.value = feed.isPublic - _categories.addAll(createCategories(feed.feedCategory)) - if (feed.imageUrls.isNotEmpty()) loadFeedImages(feed.feedId) - } ?: _categories.addAll(createCategories()) + savedStateHandle.get("FEED")?.let { feed -> + novelId = feed.novelId + _selectedNovelTitle.value = feed.novelTitle.orEmpty() + content.value = feed.feedContent + isSpoiled.value = feed.isSpoiler + isPublic.value = feed.isPublic + _categories.addAll(createCategories(feed.feedCategory)) + if (feed.imageUrls.isNotEmpty()) loadFeedImages(feed.feedId) + } ?: _categories.addAll(createCategories()) - isActivated.addSource(content) { updateIsActivated() } - } + isActivated.addSource(content) { updateIsActivated() } + } - private fun loadFeedImages(feedId: Long) { - loadExistImages(feedId) { result -> - result.onSuccess { uris -> - if (uris.isNotEmpty()) { - addCompressedImages(uris) - } + private fun loadFeedImages(feedId: Long) { + loadExistImages(feedId) { result -> + result.onSuccess { uris -> + if (uris.isNotEmpty()) { + addCompressedImages(uris) } } } + } - private fun loadExistImages( - feedId: Long, - onComplete: (Result>) -> Unit, - ) { - viewModelScope.launch { - val result = runCatching { - val feed = feedRepository.fetchFeed(feedId) - downloadAllImages(feed.images) - } - onComplete(result) + private fun loadExistImages( + feedId: Long, + onComplete: (Result>) -> Unit, + ) { + viewModelScope.launch { + val result = runCatching { + val feed = feedRepository.fetchFeed(feedId) + downloadAllImages(feed.images) } + onComplete(result) } + } - private suspend fun downloadAllImages(imageUrls: List): List { - val uris = mutableListOf() - - imageUrls.forEach { url -> - val uri = safeDownloadImage(url) - uri?.let { uris.add(it) } - } + private suspend fun downloadAllImages(imageUrls: List): List { + val uris = mutableListOf() - return uris + imageUrls.forEach { url -> + val uri = safeDownloadImage(url) + uri?.let { uris.add(it) } } - private suspend fun safeDownloadImage(url: String): Uri? = - runCatching { - feedRepository.downloadImage(url).getOrThrow() - }.onFailure { - Log.e("CreateFeedViewModel", it.message.toString()) - }.getOrNull() + return uris + } + + private suspend fun safeDownloadImage(url: String): Uri? = + runCatching { + feedRepository.downloadImage(url).getOrThrow() + }.onFailure { + Log.e("CreateFeedViewModel", it.message.toString()) + }.getOrNull() - private fun updateIsActivated() { - isActivated.value = content.value.isNullOrEmpty().not() && + private fun updateIsActivated() { + isActivated.value = content.value.isNullOrEmpty().not() && categories.any { it.isSelected } - } + } - fun createFeed() { - if (isUploading.value == true) return - viewModelScope.launch { - runCatching { - _isUploading.value = true - feedRepository.saveFeed( - relevantCategories = categories - .filter { it.isSelected } - .map { it.category.enTitle }, - feedContent = content.value.orEmpty(), - novelId = novelId, - isSpoiler = isSpoiled.value ?: false, - isPublic = isPublic.value ?: true, - images = attachedImages.value, - ) - }.onSuccess { - _isUploading.value = false - _updateFeedSuccessEvent.emit(Unit) - }.onFailure { - _isUploading.value = false - } + fun createFeed() { + if (isUploading.value == true) return + viewModelScope.launch { + runCatching { + _isUploading.value = true + feedRepository.saveFeed( + relevantCategories = categories + .filter { it.isSelected } + .map { it.category.enTitle }, + feedContent = content.value.orEmpty(), + novelId = novelId, + isSpoiler = isSpoiled.value ?: false, + isPublic = isPublic.value ?: true, + images = attachedImages.value, + ) + }.onSuccess { + _isUploading.value = false + _updateFeedSuccessEvent.emit(Unit) + }.onFailure { + _isUploading.value = false } } + } - fun editFeed( - feedId: Long, - legacyFeed: String, - ) { - if (isUploading.value == true) return - viewModelScope.launch { - runCatching { - _isUploading.value = true - feedRepository.saveEditedFeed( - feedId = feedId, - relevantCategories = categories - .filter { it.isSelected } - .map { it.category.enTitle }, - legacyFeed = legacyFeed, - editedFeed = content.value.orEmpty(), - novelId = novelId, - isSpoiler = isSpoiled.value ?: false, - isPublic = isPublic.value ?: true, - images = attachedImages.value, - ) - }.onSuccess { - _isUploading.value = false - _updateFeedSuccessEvent.emit(Unit) - }.onFailure { - _isUploading.value = false - } + fun editFeed(feedId: Long) { + if (isUploading.value == true) return + viewModelScope.launch { + runCatching { + _isUploading.value = true + feedRepository.saveEditedFeed( + feedId = feedId, + relevantCategories = categories + .filter { it.isSelected } + .map { it.category.enTitle }, + editedFeed = content.value.orEmpty(), + novelId = novelId, + isSpoiler = isSpoiled.value ?: false, + isPublic = isPublic.value ?: true, + images = attachedImages.value, + ) + }.onSuccess { + _isUploading.value = false + _updateFeedSuccessEvent.emit(Unit) + }.onFailure { + _isUploading.value = false } } + } - fun updateSelectedCategory(category: String) { - categories.forEachIndexed { index, categoryModel -> - _categories[index] = when (categoryModel.category.enTitle == category) { - true -> categoryModel.copy(isSelected = !categoryModel.isSelected) - false -> return@forEachIndexed - } + fun updateSelectedCategory(category: String) { + categories.forEachIndexed { index, categoryModel -> + _categories[index] = when (categoryModel.category.enTitle == category) { + true -> categoryModel.copy(isSelected = !categoryModel.isSelected) + false -> return@forEachIndexed } - - updateIsActivated() } - fun updateSearchedNovels(typingText: String) { - searchNovelUiState.value?.let { searchNovelUiState -> - if (searchedText == typingText) return + updateIsActivated() + } + + fun updateSearchedNovels(typingText: String) { + searchNovelUiState.value?.let { searchNovelUiState -> + if (searchedText == typingText) return - viewModelScope.launch { - _searchNovelUiState.value = searchNovelUiState.copy(loading = true) - runCatching { - getSearchedNovelsUseCase(typingText) - }.onSuccess { result -> - _searchNovelUiState.value = searchNovelUiState.copy( - loading = false, - isLoadable = result.isLoadable, - novelCount = result.resultCount, - novels = result.novels.map { novel -> - if (novel.id == novelId) { - novel - .toUi() - .let { it.copy(isSelected = !it.isSelected) } - } else { - novel.toUi() - } - }, - ) - searchedText = typingText - }.onFailure { - _searchNovelUiState.value = searchNovelUiState.copy( - loading = false, - error = true, - ) - } + viewModelScope.launch { + _searchNovelUiState.value = searchNovelUiState.copy(loading = true) + runCatching { + getSearchedNovelsUseCase(typingText) + }.onSuccess { result -> + _searchNovelUiState.value = searchNovelUiState.copy( + loading = false, + isLoadable = result.isLoadable, + novelCount = result.resultCount, + novels = result.novels.map { novel -> + if (novel.id == novelId) { + novel + .toUi() + .let { it.copy(isSelected = !it.isSelected) } + } else { + novel.toUi() + } + }, + ) + searchedText = typingText + }.onFailure { + _searchNovelUiState.value = searchNovelUiState.copy( + loading = false, + error = true, + ) } } } + } - fun updateSearchedNovels() { - searchNovelUiState.value?.let { searchNovelUiState -> - if (!searchNovelUiState.isLoadable) return + fun updateSearchedNovels() { + searchNovelUiState.value?.let { searchNovelUiState -> + if (!searchNovelUiState.isLoadable) return - viewModelScope.launch { - runCatching { - getSearchedNovelsUseCase() - }.onSuccess { result -> - _searchNovelUiState.value = searchNovelUiState.copy( - loading = false, - isLoadable = result.isLoadable, - novelCount = result.resultCount, - novels = result.novels.map { novel -> - if (novel.id == novelId) { - novel - .toUi() - .let { it.copy(isSelected = !it.isSelected) } - } else { - novel.toUi() - } - }, - ) - }.onFailure { - _searchNovelUiState.value = searchNovelUiState.copy( - loading = false, - error = true, - ) - } + viewModelScope.launch { + runCatching { + getSearchedNovelsUseCase() + }.onSuccess { result -> + _searchNovelUiState.value = searchNovelUiState.copy( + loading = false, + isLoadable = result.isLoadable, + novelCount = result.resultCount, + novels = result.novels.map { novel -> + if (novel.id == novelId) { + novel + .toUi() + .let { it.copy(isSelected = !it.isSelected) } + } else { + novel.toUi() + } + }, + ) + }.onFailure { + _searchNovelUiState.value = searchNovelUiState.copy( + loading = false, + error = true, + ) } } } + } - fun updateSelectedNovel(novelId: Long) { - searchNovelUiState.value?.let { searchNovelUiState -> - val novels = searchNovelUiState.novels.map { novel -> - if (novel.id == novelId) { - novel.copy(isSelected = !novel.isSelected) - } else { - novel.copy(isSelected = false) - } + fun updateSelectedNovel(novelId: Long) { + searchNovelUiState.value?.let { searchNovelUiState -> + val novels = searchNovelUiState.novels.map { novel -> + if (novel.id == novelId) { + novel.copy(isSelected = !novel.isSelected) + } else { + novel.copy(isSelected = false) } - _searchNovelUiState.value = searchNovelUiState.copy(novels = novels) } + _searchNovelUiState.value = searchNovelUiState.copy(novels = novels) } + } - fun updateSelectedNovel() { - searchNovelUiState.value?.let { searchNovelUiState -> - val novel = searchNovelUiState.novels.find { it.isSelected } - _selectedNovelTitle.value = novel?.title.orEmpty() - novelId = novel?.id - } + fun updateSelectedNovel() { + searchNovelUiState.value?.let { searchNovelUiState -> + val novel = searchNovelUiState.novels.find { it.isSelected } + _selectedNovelTitle.value = novel?.title.orEmpty() + novelId = novel?.id } + } - fun updateSelectedNovelClear() { - searchNovelUiState.value?.let { searchNovelUiState -> - val novels = searchNovelUiState.novels.map { novel -> - novel.copy(isSelected = false) - } - _searchNovelUiState.value = searchNovelUiState.copy(novels = novels) - _selectedNovelTitle.value = "" + fun updateSelectedNovelClear() { + searchNovelUiState.value?.let { searchNovelUiState -> + val novels = searchNovelUiState.novels.map { novel -> + novel.copy(isSelected = false) } + _searchNovelUiState.value = searchNovelUiState.copy(novels = novels) + _selectedNovelTitle.value = "" } + } - fun addImages(newImages: List) { - val current = _attachedImages.value - val remaining = MAX_IMAGE_COUNT - current.size + fun addImages(newImages: List) { + val current = _attachedImages.value + val remaining = MAX_IMAGE_COUNT - current.size - if (remaining >= newImages.size) { - addCompressedImages(newImages) - } else { - _exceedingImageCountEvent.tryEmit(Unit) - } + if (remaining >= newImages.size) { + addCompressedImages(newImages) + } else { + _exceedingImageCountEvent.tryEmit(Unit) } + } - private fun addCompressedImages( - newImages: List, - retryCount: Int = 0, - ) { - if (retryCount > MAX_RETRY_COUNT) return + private fun addCompressedImages( + newImages: List, + retryCount: Int = 0, + ) { + if (retryCount > MAX_RETRY_COUNT) return - viewModelScope.launch { - runCatching { - feedRepository.compressImages(newImages) - }.onSuccess { compressedImages -> - _attachedImages.value = attachedImages.value + compressedImages - }.onFailure { - addCompressedImages(newImages, retryCount + 1) - } + viewModelScope.launch { + runCatching { + feedRepository.compressImages(newImages) + }.onSuccess { compressedImages -> + _attachedImages.value = attachedImages.value + compressedImages + }.onFailure { + addCompressedImages(newImages, retryCount + 1) } } + } - fun removeImage(index: Int) { - attachedImages.value - .toMutableList() - .also { image -> if (index in image.indices) image.removeAt(index) } - .let { _attachedImages.value = it } - } + fun removeImage(index: Int) { + attachedImages.value + .toMutableList() + .also { image -> if (index in image.indices) image.removeAt(index) } + .let { _attachedImages.value = it } + } - companion object { - const val MAX_IMAGE_COUNT: Int = 5 - private const val MAX_RETRY_COUNT: Int = 3 - } + companion object { + const val MAX_IMAGE_COUNT: Int = 5 + private const val MAX_RETRY_COUNT: Int = 3 } +} diff --git a/app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailActivity.kt b/app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailActivity.kt index 137b18772..0829ac220 100644 --- a/app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailActivity.kt @@ -52,7 +52,7 @@ import com.into.websoso.ui.createFeed.CreateFeedActivity import com.into.websoso.ui.expandedFeedImage.ExpandedFeedImageActivity import com.into.websoso.ui.feedDetail.FeedDetailActivity.MenuType.COMMENT import com.into.websoso.ui.feedDetail.FeedDetailActivity.MenuType.FEED -import com.into.websoso.ui.feedDetail.FeedDetailViewModel.Companion.DEFAULT_NOTIFICATION_ID +import com.into.websoso.ui.feedDetail.UpdatedFeedDetailViewModel.Companion.DEFAULT_NOTIFICATION_ID import com.into.websoso.ui.feedDetail.adapter.FeedDetailAdapter import com.into.websoso.ui.feedDetail.adapter.FeedDetailType.Comment import com.into.websoso.ui.feedDetail.adapter.FeedDetailType.Header @@ -81,8 +81,7 @@ class FeedDetailActivity : BaseActivity(activity_feed private enum class MenuType { COMMENT, FEED } private val feedId: Long by lazy { intent.getLongExtra(FEED_ID, DEFAULT_FEED_ID) } - private val isLiked: Boolean by lazy { intent.getBooleanExtra(FEED_LIKE_STATUS, false) } - private val feedDetailViewModel: FeedDetailViewModel by viewModels() + private val feedDetailViewModel: UpdatedFeedDetailViewModel by viewModels() private val feedDetailAdapter: FeedDetailAdapter by lazy { FeedDetailAdapter( onFeedContentClick(), @@ -122,7 +121,7 @@ class FeedDetailActivity : BaseActivity(activity_feed singleEventHandler.debounce(timeMillis = 100L, coroutineScope = lifecycleScope) { tracker.trackEvent("feed_detail_like") - feedDetailViewModel.updateLike(view.isSelected, updatedLikeCount) + feedDetailViewModel.updateLike() } } @@ -373,7 +372,7 @@ class FeedDetailActivity : BaseActivity(activity_feed NovelDetailBack.RESULT_OK, CreateFeed.RESULT_OK, OtherUserProfileBack.RESULT_OK, - -> { + -> { feedDetailViewModel.updateFeedDetail(feedId, CreateFeed) } @@ -465,7 +464,7 @@ class FeedDetailActivity : BaseActivity(activity_feed } private fun setupView() { - feedDetailViewModel.updateFeedDetail(feedId, Feed, isLiked) + feedDetailViewModel.updateFeedDetail(feedId, Feed) binding.rvFeedDetail.apply { adapter = feedDetailAdapter itemAnimator = null diff --git a/app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailViewModel.kt b/app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailViewModel.kt index f1bd549a9..dd05fdac3 100644 --- a/app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailViewModel.kt @@ -22,310 +22,306 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import javax.inject.Inject +@Deprecated("피드 QA 완료 후 제거 예정") @HiltViewModel class FeedDetailViewModel - @Inject - constructor( - private val feedRepository: FeedRepository, - private val userRepository: UserRepository, - private val notificationRepository: NotificationRepository, - ) : ViewModel() { - private var feedId: Long = -1 - private val _feedDetailUiState: MutableLiveData = - MutableLiveData(FeedDetailUiState()) - val feedDetailUiState: LiveData get() = _feedDetailUiState - var commentId: Long = -1 - private set +@Inject +constructor( + private val feedRepository: FeedRepository, + private val userRepository: UserRepository, + private val notificationRepository: NotificationRepository, +) : ViewModel() { + private var feedId: Long = -1 + private val _feedDetailUiState: MutableLiveData = + MutableLiveData(FeedDetailUiState()) + val feedDetailUiState: LiveData get() = _feedDetailUiState + var commentId: Long = -1 + private set - fun updateCommentId(commentId: Long) { - this.commentId = commentId - } - - fun updateFeedDetail( - feedId: Long, - from: ResultFrom, - isLiked: Boolean = false, - ) { - this.feedId = feedId - feedDetailUiState.value?.let { feedDetailUiState -> - viewModelScope.launch { - delay(300) - _feedDetailUiState.value = feedDetailUiState.copy(loading = true) + fun updateCommentId(commentId: Long) { + this.commentId = commentId + } - runCatching { - coroutineScope { - awaitAll( - async { userRepository.fetchMyProfile() }, - async { feedRepository.fetchFeed(feedId) }, - async { feedRepository.fetchComments(feedId) }, - ) - } - }.onSuccess { result -> - val myProfile = result[0] as MyProfileEntity - val feedDetail = (result[1] as FeedDetailEntity) - val comments = result[2] as CommentsEntity + fun updateFeedDetail( + feedId: Long, + from: ResultFrom, + isLiked: Boolean = false, + ) { + this.feedId = feedId + feedDetailUiState.value?.let { feedDetailUiState -> + viewModelScope.launch { + delay(300) + _feedDetailUiState.value = feedDetailUiState.copy(loading = true) - val uiFeed = feedDetail.toUi().feed - val updatedFeed = if (feedDetail.isLiked == isLiked) { - uiFeed - } else if (!isLiked && feedDetail.isLiked) { - uiFeed?.copy( - isLiked = false, - likeCount = feedDetail.likeCount - 1, - ) - } else { - uiFeed?.copy( - isLiked = true, - likeCount = feedDetail.likeCount + 1, - ) - } + runCatching { + coroutineScope { + awaitAll( + async { userRepository.fetchMyProfile() }, + async { feedRepository.fetchFeed(feedId) }, + async { feedRepository.fetchComments(feedId) }, + ) + } + }.onSuccess { result -> + val myProfile = result[0] as MyProfileEntity + val feedDetail = (result[1] as FeedDetailEntity) + val comments = result[2] as CommentsEntity - _feedDetailUiState.value = feedDetailUiState.copy( - loading = false, - isRefreshed = true, - feedDetail = FeedDetailModel( - feed = updatedFeed, - comments = comments.comments.map { it.toUi() }, - user = FeedDetailModel.UserModel( - avatarImage = myProfile.avatarImage, - ), - novel = feedDetail.novel, - ), + val uiFeed = feedDetail.toUi().feed + val updatedFeed = if (feedDetail.isLiked == isLiked) { + uiFeed + } else if (!isLiked && feedDetail.isLiked) { + uiFeed?.copy( + isLiked = false, + likeCount = feedDetail.likeCount - 1, ) - }.onFailure { - _feedDetailUiState.value = feedDetailUiState.copy( - loading = false, - error = true, - previousStack = FeedDetailUiState.PreviousStack(from), + } else { + uiFeed?.copy( + isLiked = true, + likeCount = feedDetail.likeCount + 1, ) } + + _feedDetailUiState.value = feedDetailUiState.copy( + loading = false, + isRefreshed = true, + feedDetail = FeedDetailModel( + feed = updatedFeed, + comments = comments.comments.map { it.toUi() }, + user = FeedDetailModel.UserModel( + avatarImage = myProfile.avatarImage, + ), + novel = feedDetail.novel, + ), + ) + }.onFailure { + _feedDetailUiState.value = feedDetailUiState.copy( + loading = false, + error = true, + previousStack = FeedDetailUiState.PreviousStack(from), + ) } } } + } - fun updateReportedSpoilerFeed() { - feedDetailUiState.value?.let { feedDetailUiState -> - viewModelScope.launch { - _feedDetailUiState.value = feedDetailUiState.copy(loading = true) - runCatching { - feedRepository.saveSpoilerFeed(feedId) - }.onSuccess { - _feedDetailUiState.value = feedDetailUiState.copy(loading = false) - }.onFailure { - _feedDetailUiState.value = feedDetailUiState.copy( - loading = false, - isReported = true, - ) - } + fun updateReportedSpoilerFeed() { + feedDetailUiState.value?.let { feedDetailUiState -> + viewModelScope.launch { + _feedDetailUiState.value = feedDetailUiState.copy(loading = true) + runCatching { + feedRepository.saveSpoilerFeed(feedId) + }.onSuccess { + _feedDetailUiState.value = feedDetailUiState.copy(loading = false) + }.onFailure { + _feedDetailUiState.value = feedDetailUiState.copy( + loading = false, + isReported = true, + ) } } } + } - fun updateReportedImpertinenceFeed() { - feedDetailUiState.value?.let { feedDetailUiState -> - viewModelScope.launch { - _feedDetailUiState.value = feedDetailUiState.copy(loading = true) - runCatching { - feedRepository.saveImpertinenceFeed(feedId) - }.onSuccess { - _feedDetailUiState.value = feedDetailUiState.copy(loading = false) - }.onFailure { - _feedDetailUiState.value = feedDetailUiState.copy( - loading = false, - isReported = true, - ) - } + fun updateReportedImpertinenceFeed() { + feedDetailUiState.value?.let { feedDetailUiState -> + viewModelScope.launch { + _feedDetailUiState.value = feedDetailUiState.copy(loading = true) + runCatching { + feedRepository.saveImpertinenceFeed(feedId) + }.onSuccess { + _feedDetailUiState.value = feedDetailUiState.copy(loading = false) + }.onFailure { + _feedDetailUiState.value = feedDetailUiState.copy( + loading = false, + isReported = true, + ) } } } + } - fun updateRemovedFeed() { - feedDetailUiState.value?.let { feedDetailUiState -> - viewModelScope.launch { - _feedDetailUiState.value = feedDetailUiState.copy(loading = true) - val removedFeed = feedDetailUiState.feedDetail.feed - runCatching { - feedRepository.saveRemovedFeed( - feedId = feedId, - novelId = removedFeed?.novel?.id, - content = removedFeed?.content.orEmpty(), - ) - }.onSuccess { - _feedDetailUiState.value = feedDetailUiState.copy( - loading = false, - ) - }.onFailure { - _feedDetailUiState.value = feedDetailUiState.copy( - loading = false, - error = true, - ) - } + fun updateRemovedFeed() { + feedDetailUiState.value?.let { feedDetailUiState -> + viewModelScope.launch { + _feedDetailUiState.value = feedDetailUiState.copy(loading = true) + runCatching { + feedRepository.saveRemovedFeed(feedId = feedId) + }.onSuccess { + _feedDetailUiState.value = feedDetailUiState.copy( + loading = false, + ) + }.onFailure { + _feedDetailUiState.value = feedDetailUiState.copy( + loading = false, + error = true, + ) } } } + } - fun updateLike( - isLiked: Boolean, - updatedLikeCount: Int, - ) { - feedDetailUiState.value?.let { feedDetailUiState -> - val feed = feedDetailUiState.feedDetail.feed ?: throw IllegalArgumentException() - if (feed.isLiked == isLiked) return + fun updateLike( + isLiked: Boolean, + updatedLikeCount: Int, + ) { + feedDetailUiState.value?.let { feedDetailUiState -> + val feed = feedDetailUiState.feedDetail.feed ?: throw IllegalArgumentException() + if (feed.isLiked == isLiked) return - viewModelScope.launch { - runCatching { - feedRepository.saveLike(feed.isLiked, feedId) - }.onSuccess { - _feedDetailUiState.value = feedDetailUiState.copy( - feedDetail = feedDetailUiState.feedDetail.copy( - feed = feedDetailUiState.feedDetail.feed.copy( - isLiked = isLiked, - likeCount = updatedLikeCount, - ), + viewModelScope.launch { + runCatching { + feedRepository.saveLike(feed.isLiked, feedId) + }.onSuccess { + _feedDetailUiState.value = feedDetailUiState.copy( + feedDetail = feedDetailUiState.feedDetail.copy( + feed = feedDetailUiState.feedDetail.feed.copy( + isLiked = isLiked, + likeCount = updatedLikeCount, ), - ) - }.onFailure { - _feedDetailUiState.value = feedDetailUiState.copy( - isServerError = true, - ) - } + ), + ) + }.onFailure { + _feedDetailUiState.value = feedDetailUiState.copy( + isServerError = true, + ) } } } + } - fun dispatchComment(comment: String) { - feedDetailUiState.value?.let { feedDetailUiState -> - viewModelScope.launch { - runCatching { - feedRepository.saveComment(feedId, comment) - }.onSuccess { - updateComments(feedId) - }.onFailure { - _feedDetailUiState.value = feedDetailUiState.copy( - loading = false, - isServerError = true, - ) - } + fun dispatchComment(comment: String) { + feedDetailUiState.value?.let { feedDetailUiState -> + viewModelScope.launch { + runCatching { + feedRepository.saveComment(feedId, comment) + }.onSuccess { + updateComments(feedId) + }.onFailure { + _feedDetailUiState.value = feedDetailUiState.copy( + loading = false, + isServerError = true, + ) } } } + } - fun modifyComment(comment: String) { - feedDetailUiState.value?.let { feedDetailUiState -> - viewModelScope.launch { - runCatching { - feedRepository.saveModifiedComment(feedId, commentId, comment) - }.onSuccess { - updateComments(feedId) - }.onFailure { - _feedDetailUiState.value = feedDetailUiState.copy( - loading = false, - isServerError = true, - ) - } + fun modifyComment(comment: String) { + feedDetailUiState.value?.let { feedDetailUiState -> + viewModelScope.launch { + runCatching { + feedRepository.saveModifiedComment(feedId, commentId, comment) + }.onSuccess { + updateComments(feedId) + }.onFailure { + _feedDetailUiState.value = feedDetailUiState.copy( + loading = false, + isServerError = true, + ) } } } + } - private fun updateComments(feedId: Long) { - feedDetailUiState.value?.let { feedDetailUiState -> - viewModelScope.launch { - runCatching { - feedRepository.fetchComments(feedId) - }.onSuccess { commentsEntity -> - val comments = commentsEntity.comments.map { it.toUi() } + private fun updateComments(feedId: Long) { + feedDetailUiState.value?.let { feedDetailUiState -> + viewModelScope.launch { + runCatching { + feedRepository.fetchComments(feedId) + }.onSuccess { commentsEntity -> + val comments = commentsEntity.comments.map { it.toUi() } - _feedDetailUiState.value = feedDetailUiState.copy( - loading = false, - isRefreshed = false, - feedDetail = feedDetailUiState.feedDetail.copy( - feed = feedDetailUiState.feedDetail.feed?.copy(commentCount = comments.size), - comments = comments, - ), - ) - }.onFailure { - _feedDetailUiState.value = feedDetailUiState.copy( - loading = false, - isServerError = true, - ) - } + _feedDetailUiState.value = feedDetailUiState.copy( + loading = false, + isRefreshed = false, + feedDetail = feedDetailUiState.feedDetail.copy( + feed = feedDetailUiState.feedDetail.feed?.copy(commentCount = comments.size), + comments = comments, + ), + ) + }.onFailure { + _feedDetailUiState.value = feedDetailUiState.copy( + loading = false, + isServerError = true, + ) } } } + } - fun updateReportedSpoilerComment(commentId: Long) { - feedDetailUiState.value?.let { feedDetailUiState -> - viewModelScope.launch { - _feedDetailUiState.value = feedDetailUiState.copy(loading = true) - runCatching { - feedRepository.saveSpoilerComment(feedId, commentId) - }.onSuccess { - _feedDetailUiState.value = feedDetailUiState.copy(loading = false) - }.onFailure { - _feedDetailUiState.value = feedDetailUiState.copy( - loading = false, - isReported = true, - ) - } + fun updateReportedSpoilerComment(commentId: Long) { + feedDetailUiState.value?.let { feedDetailUiState -> + viewModelScope.launch { + _feedDetailUiState.value = feedDetailUiState.copy(loading = true) + runCatching { + feedRepository.saveSpoilerComment(feedId, commentId) + }.onSuccess { + _feedDetailUiState.value = feedDetailUiState.copy(loading = false) + }.onFailure { + _feedDetailUiState.value = feedDetailUiState.copy( + loading = false, + isReported = true, + ) } } } + } - fun updateReportedImpertinenceComment(commentId: Long) { - feedDetailUiState.value?.let { feedDetailUiState -> - viewModelScope.launch { - _feedDetailUiState.value = feedDetailUiState.copy(loading = true) - runCatching { - feedRepository.saveImpertinenceComment(feedId, commentId) - }.onSuccess { - _feedDetailUiState.value = feedDetailUiState.copy(loading = false) - }.onFailure { - _feedDetailUiState.value = feedDetailUiState.copy( - loading = false, - isReported = true, - ) - } + fun updateReportedImpertinenceComment(commentId: Long) { + feedDetailUiState.value?.let { feedDetailUiState -> + viewModelScope.launch { + _feedDetailUiState.value = feedDetailUiState.copy(loading = true) + runCatching { + feedRepository.saveImpertinenceComment(feedId, commentId) + }.onSuccess { + _feedDetailUiState.value = feedDetailUiState.copy(loading = false) + }.onFailure { + _feedDetailUiState.value = feedDetailUiState.copy( + loading = false, + isReported = true, + ) } } } + } - fun updateRemovedComment(commentId: Long) { - feedDetailUiState.value?.let { feedDetailUiState -> - viewModelScope.launch { - _feedDetailUiState.value = feedDetailUiState.copy(loading = true) - runCatching { - feedRepository.deleteComment(feedId, commentId) - }.onSuccess { - val comments = - feedDetailUiState.feedDetail.comments.filter { it.commentId != commentId } + fun updateRemovedComment(commentId: Long) { + feedDetailUiState.value?.let { feedDetailUiState -> + viewModelScope.launch { + _feedDetailUiState.value = feedDetailUiState.copy(loading = true) + runCatching { + feedRepository.deleteComment(feedId, commentId) + }.onSuccess { + val comments = + feedDetailUiState.feedDetail.comments.filter { it.commentId != commentId } - _feedDetailUiState.value = feedDetailUiState.copy( - loading = false, - feedDetail = feedDetailUiState.feedDetail.copy( - feed = feedDetailUiState.feedDetail.feed?.copy(commentCount = comments.size), - comments = comments, - ), - ) - }.onFailure { - _feedDetailUiState.value = feedDetailUiState.copy( - loading = false, - isServerError = true, - ) - } + _feedDetailUiState.value = feedDetailUiState.copy( + loading = false, + feedDetail = feedDetailUiState.feedDetail.copy( + feed = feedDetailUiState.feedDetail.feed?.copy(commentCount = comments.size), + comments = comments, + ), + ) + }.onFailure { + _feedDetailUiState.value = feedDetailUiState.copy( + loading = false, + isServerError = true, + ) } } } + } - fun updateNotificationRead(notificationId: Long) { - if (notificationId == DEFAULT_NOTIFICATION_ID) return - viewModelScope.launch { - runCatching { - notificationRepository.fetchNotificationRead(notificationId) - } + fun updateNotificationRead(notificationId: Long) { + if (notificationId == DEFAULT_NOTIFICATION_ID) return + viewModelScope.launch { + runCatching { + notificationRepository.fetchNotificationRead(notificationId) } } + } - companion object { - const val DEFAULT_NOTIFICATION_ID: Long = -1 - } + companion object { + const val DEFAULT_NOTIFICATION_ID: Long = -1 } +} diff --git a/app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt b/app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt new file mode 100644 index 000000000..d6e272889 --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt @@ -0,0 +1,304 @@ +package com.into.websoso.ui.feedDetail + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.into.websoso.core.common.ui.model.ResultFrom +import com.into.websoso.data.feed.model.CommentEntity +import com.into.websoso.data.feed.repository.UpdatedFeedRepository +import com.into.websoso.data.model.FeedDetailEntity +import com.into.websoso.data.repository.NotificationRepository +import com.into.websoso.data.repository.UserRepository +import com.into.websoso.ui.feedDetail.model.FeedDetailModel +import com.into.websoso.ui.feedDetail.model.FeedDetailUiState +import com.into.websoso.ui.mapper.toCommentModel +import com.into.websoso.ui.mapper.toFeedDetailModel +import com.into.websoso.ui.mapper.toFeedModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class UpdatedFeedDetailViewModel @Inject constructor( + private val feedRepository: UpdatedFeedRepository, + private val userRepository: UserRepository, + private val notificationRepository: NotificationRepository, +) : ViewModel() { + + private var feedId: Long = -1 + var commentId: Long = -1 + private set + private var feedFlowJob: Job? = null + + private val _feedDetailUiState: MutableLiveData = + MutableLiveData(FeedDetailUiState()) + val feedDetailUiState: LiveData get() = _feedDetailUiState + + fun updateCommentId(newCommentId: Long) { + this.commentId = newCommentId + } + + /** + * 피드 상세 화면 진입 시 호출되며, 상세 정보, 프로필, 댓글 데이터를 병렬로 로드하고 스트림 구독을 시작합니다. + */ + fun updateFeedDetail( + feedId: Long, + from: ResultFrom, + ) { + this.feedId = feedId + feedFlowJob?.cancel() + + _feedDetailUiState.value = _feedDetailUiState.value?.copy( + loading = true, + previousStack = FeedDetailUiState.PreviousStack(from), + ) ?: FeedDetailUiState() + + viewModelScope.launch { + launch { fetchSingleFeed(feedId) } + launch { fetchMyProfile() } + launch { fetchComments(feedId) } + observeFeedStream(feedId) + } + } + + /** + * 리포지토리의 여러 피드 소스를 결합하여 현재 피드의 인터랙션 변경 사항(좋아요, 댓글 수)을 실시간으로 관찰합니다. + */ + private fun observeFeedStream(targetFeedId: Long) { + feedFlowJob = viewModelScope.launch { + combine( + feedRepository.sosoAllFeeds, + feedRepository.sosoRecommendedFeeds, + feedRepository.myFeeds, + ) { sosoAllFeeds, sosoRecommendedFeeds, myFeeds -> + sosoAllFeeds.find { it.id == targetFeedId } + ?: sosoRecommendedFeeds.find { it.id == targetFeedId } + ?: myFeeds.find { it.id == targetFeedId } + } + .distinctUntilChanged() + .collect { feedEntity -> + if (feedEntity != null) { + val currentUiState = _feedDetailUiState.value ?: FeedDetailUiState() + _feedDetailUiState.value = currentUiState.copy( + loading = false, + isRefreshed = true, + feedDetail = currentUiState.feedDetail.copy( + feed = feedEntity.toFeedModel(), + novel = currentUiState.feedDetail.novel, + ), + ) + } + } + } + } + + /** + * 서버에서 단건 조회를 수행하여 소설 줄거리, 작가 정보 등 상세 데이터를 로드합니다. + */ + private suspend fun fetchSingleFeed(targetFeedId: Long) { + runCatching { + feedRepository.fetchFeed(targetFeedId) + }.onSuccess { result -> + val currentUiState = _feedDetailUiState.value ?: return@onSuccess + _feedDetailUiState.value = currentUiState.copy( + loading = false, + isRefreshed = true, + feedDetail = currentUiState.feedDetail.copy( + feed = result.toFeedDetailModel(), + novel = result.novel?.let { novelEntity -> + FeedDetailEntity.NovelEntity( + id = novelEntity.id, + title = novelEntity.title, + rating = novelEntity.rating, + ratingCount = novelEntity.ratingCount, + thumbnail = novelEntity.thumbnail, + genre = novelEntity.genre, + author = novelEntity.author, + description = novelEntity.description, + feedWriterNovelRating = novelEntity.feedWriterNovelRating, + ) + }, + ), + ) + }.onFailure { + val currentUiState = _feedDetailUiState.value ?: return@onFailure + if (currentUiState.feedDetail.feed == null) { + _feedDetailUiState.value = currentUiState.copy(loading = false, error = true) + } + } + } + + /** + * 좋아요 상태를 로컬 캐시에서 즉시 토글합니다. + */ + fun updateLike() { + if (feedId == -1L) return + feedRepository.toggleLikeLocal(feedId) + } + + /** + * 화면 이탈 시 로컬의 변경 사항을 서버와 동기화합니다. + */ + override fun onCleared() { + super.onCleared() + feedRepository.syncDirtyFeeds() + } + + /** + * 내 프로필 정보를 조회하여 UI 상태를 갱신합니다. + */ + private suspend fun fetchMyProfile() { + runCatching { userRepository.fetchMyProfile() } + .onSuccess { profile -> + val currentUiState = _feedDetailUiState.value ?: return@onSuccess + _feedDetailUiState.value = currentUiState.copy( + feedDetail = currentUiState.feedDetail.copy( + user = FeedDetailModel.UserModel(avatarImage = profile.avatarImage), + ), + ) + } + } + + /** + * 피드의 댓글 목록을 조회하여 UI 상태를 갱신합니다. + */ + private suspend fun fetchComments(targetFeedId: Long) { + runCatching { feedRepository.fetchComments(targetFeedId) } + .onSuccess { entity -> + val currentUiState = _feedDetailUiState.value ?: return@onSuccess + val uiComments = entity.comments.map(CommentEntity::toCommentModel) + _feedDetailUiState.value = currentUiState.copy( + feedDetail = currentUiState.feedDetail.copy( + comments = uiComments, + feed = currentUiState.feedDetail.feed?.copy(commentCount = uiComments.size), + ), + ) + } + } + + /** + * 새 댓글을 작성하고 목록을 갱신합니다. + */ + fun dispatchComment(comment: String) { + viewModelScope.launch { + runCatching { feedRepository.saveComment(feedId, comment) } + .onSuccess { fetchComments(feedId) } + .onFailure { + _feedDetailUiState.value = + _feedDetailUiState.value?.copy(isServerError = true) + } + } + } + + /** + * 기존 댓글을 수정하고 목록을 갱신합니다. + */ + fun modifyComment(comment: String) { + viewModelScope.launch { + runCatching { feedRepository.saveModifiedComment(feedId, commentId, comment) } + .onSuccess { fetchComments(feedId) } + .onFailure { + _feedDetailUiState.value = + _feedDetailUiState.value?.copy(isServerError = true) + } + } + } + + /** + * 댓글을 삭제하고 목록을 갱신합니다. + */ + fun updateRemovedComment(targetCommentId: Long) { + viewModelScope.launch { + _feedDetailUiState.value = _feedDetailUiState.value?.copy(loading = true) + runCatching { feedRepository.deleteComment(feedId, targetCommentId) } + .onSuccess { + val currentUiState = _feedDetailUiState.value ?: return@onSuccess + val newComments = + currentUiState.feedDetail.comments.filter { it.commentId != targetCommentId } + + _feedDetailUiState.value = currentUiState.copy( + loading = false, + feedDetail = currentUiState.feedDetail.copy( + comments = newComments, + feed = currentUiState.feedDetail.feed?.copy(commentCount = newComments.size), + ), + ) + } + .onFailure { + _feedDetailUiState.value = + _feedDetailUiState.value?.copy(loading = false, isServerError = true) + } + } + } + + /** + * 피드를 삭제하고 로컬 캐시 및 UI에 반영합니다. + */ + fun updateRemovedFeed() { + viewModelScope.launch { + _feedDetailUiState.value = _feedDetailUiState.value?.copy(loading = true) + runCatching { feedRepository.saveRemovedFeed(feedId) } + .onSuccess { + _feedDetailUiState.value = _feedDetailUiState.value?.copy(loading = false) + } + .onFailure { + _feedDetailUiState.value = + _feedDetailUiState.value?.copy(loading = false, error = true) + } + } + } + + /** + * 피드를 스포일러로 신고합니다. + */ + fun updateReportedSpoilerFeed() { + viewModelScope.launch { + runCatching { feedRepository.saveSpoilerFeed(feedId) } + } + } + + /** + * 피드를 부적절한 게시물로 신고합니다. + */ + fun updateReportedImpertinenceFeed() { + viewModelScope.launch { + runCatching { feedRepository.saveImpertinenceFeed(feedId) } + } + } + + /** + * 댓글을 스포일러로 신고합니다. + */ + fun updateReportedSpoilerComment(targetCommentId: Long) { + viewModelScope.launch { + runCatching { feedRepository.saveSpoilerComment(feedId, targetCommentId) } + } + } + + /** + * 댓글을 부적절한 내용으로 신고합니다. + */ + fun updateReportedImpertinenceComment(targetCommentId: Long) { + viewModelScope.launch { + runCatching { feedRepository.saveImpertinenceComment(feedId, targetCommentId) } + } + } + + /** + * 알림 읽음 처리를 수행합니다. + */ + fun updateNotificationRead(notificationId: Long) { + if (notificationId == DEFAULT_NOTIFICATION_ID) return + viewModelScope.launch { + runCatching { notificationRepository.fetchNotificationRead(notificationId) } + } + } + + companion object { + const val DEFAULT_NOTIFICATION_ID: Long = -1 + } +} diff --git a/app/src/main/java/com/into/websoso/ui/main/feed/FeedFragment.kt b/app/src/main/java/com/into/websoso/ui/main/feed/FeedFragment.kt index 15c6709e6..ecb3ef2f0 100644 --- a/app/src/main/java/com/into/websoso/ui/main/feed/FeedFragment.kt +++ b/app/src/main/java/com/into/websoso/ui/main/feed/FeedFragment.kt @@ -16,8 +16,9 @@ import com.into.websoso.core.designsystem.theme.WebsosoTheme import com.into.websoso.databinding.DialogRemovePopupMenuBinding import com.into.websoso.databinding.DialogReportPopupMenuBinding import com.into.websoso.databinding.FragmentFeedBinding -import com.into.websoso.feature.feed.FeedRoute import com.into.websoso.feature.feed.FeedViewModel +import com.into.websoso.feature.feed.UpdateFeedRoute +import com.into.websoso.feature.feed.UpdatedFeedViewModel import com.into.websoso.ui.createFeed.CreateFeedActivity import com.into.websoso.ui.feedDetail.FeedDetailActivity import com.into.websoso.ui.feedDetail.model.EditFeedModel @@ -36,6 +37,7 @@ class FeedFragment : BaseFragment(fragment_feed) { @Inject lateinit var tracker: Tracker private val feedViewModel: FeedViewModel by viewModels() + private val updatedFeedViewModel: UpdatedFeedViewModel by viewModels() override fun onCreateView( inflater: LayoutInflater, @@ -49,8 +51,8 @@ class FeedFragment : BaseFragment(fragment_feed) { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { WebsosoTheme { - FeedRoute( - viewModel = feedViewModel, + UpdateFeedRoute( + viewModel = updatedFeedViewModel, onWriteClick = ::navigateToWriteFeed, onProfileClick = { userId, isMyFeed -> navigateToProfile( @@ -96,11 +98,6 @@ class FeedFragment : BaseFragment(fragment_feed) { return view } - override fun onResume() { - super.onResume() - feedViewModel.resetFeedsToInitial() - } - private fun navigateToWriteFeed() { startActivity(CreateFeedActivity.getIntent(requireContext())) } diff --git a/app/src/main/java/com/into/websoso/ui/main/library/LibraryFragment.kt b/app/src/main/java/com/into/websoso/ui/main/library/LibraryFragment.kt index b4d6e4a69..810b83c70 100644 --- a/app/src/main/java/com/into/websoso/ui/main/library/LibraryFragment.kt +++ b/app/src/main/java/com/into/websoso/ui/main/library/LibraryFragment.kt @@ -60,6 +60,12 @@ class LibraryFragment : Fragment() { } } + override fun onResume() { + super.onResume() + + libraryViewModel.refresh() + } + companion object { const val TAG = "LibraryFragment" } diff --git a/app/src/main/java/com/into/websoso/ui/mapper/FeedMapper.kt b/app/src/main/java/com/into/websoso/ui/mapper/FeedMapper.kt index 5df34250a..fe90b855d 100644 --- a/app/src/main/java/com/into/websoso/ui/mapper/FeedMapper.kt +++ b/app/src/main/java/com/into/websoso/ui/mapper/FeedMapper.kt @@ -66,6 +66,62 @@ fun FeedEntity.toUi(): FeedModel = ), ) +fun com.into.websoso.data.feed.model.FeedEntity.toFeedModel(): FeedModel = + FeedModel( + user = UserModel( + id = user.id, + nickname = user.nickname, + avatarImage = user.avatarImage, + ), + createdDate = createdDate, + id = id, + content = content, + relevantCategories = relevantCategories, + likeCount = likeCount, + commentCount = commentCount, + isModified = isModified, + isSpoiler = isSpoiler, + isLiked = isLiked, + isMyFeed = isMyFeed, + isPublic = isPublic, + imageUrls = images, + imageCount = imageCount, + novel = NovelModel( + id = novel.id, + title = novel.title, + rating = novel.rating, + ratingCount = novel.ratingCount, + ), + ) + +fun com.into.websoso.data.feed.model.FeedDetailEntity.toFeedDetailModel(): FeedModel = + FeedModel( + user = UserModel( + id = user.id, + nickname = user.nickname, + avatarImage = user.avatarImage, + ), + createdDate = createdDate, + id = id, + content = content, + relevantCategories = relevantCategories, + likeCount = likeCount, + commentCount = commentCount, + isModified = isModified, + isSpoiler = isSpoiler, + isLiked = isLiked, + isMyFeed = isMyFeed, + isPublic = isPublic, + imageUrls = images, + imageCount = imageCount, + novel = NovelModel( + id = novel?.id, + title = novel?.title, + rating = novel?.rating, + ratingCount = novel?.ratingCount, + ), + ) + fun CommentEntity.toUi(): CommentModel = CommentModel( user = UserModel( @@ -83,6 +139,23 @@ fun CommentEntity.toUi(): CommentModel = isBlocked = isBlocked, ) +fun com.into.websoso.data.feed.model.CommentEntity.toCommentModel(): CommentModel = + CommentModel( + user = UserModel( + id = user.id, + nickname = user.nickname, + avatarImage = user.avatarImage, + ), + commentContent = commentContent, + commentId = commentId, + createdDate = createdDate, + isModified = isModified, + isMyComment = isMyComment, + isHidden = isHidden, + isSpoiler = isSpoiler, + isBlocked = isBlocked, + ) + fun FeedDetailEntity.toUi(): FeedDetailModel = FeedDetailModel( feed = FeedModel( diff --git a/app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedViewModel.kt b/app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedViewModel.kt index a24b556a8..0827ee8b9 100644 --- a/app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedViewModel.kt @@ -20,222 +20,217 @@ import javax.inject.Inject @HiltViewModel class NovelFeedViewModel - @Inject - constructor( - private val novelRepository: NovelRepository, - private val feedRepository: FeedRepository, - private val userRepository: UserRepository, - ) : ViewModel() { - private var userId: Long = -1 - private val _feedUiState: MutableLiveData = - MutableLiveData(NovelFeedUiState()) - val feedUiState: LiveData get() = _feedUiState - private val _isRefreshed: MutableLiveData = MutableLiveData(false) - val isRefreshed: LiveData get() = _isRefreshed - private val _isLogin: MutableLiveData = MutableLiveData(false) - val isLogin: LiveData get() = _isLogin - - fun updateFeeds( - novelId: Long, - isAgainRefresh: Boolean = false, - ) { - feedUiState.value?.let { feedUiState -> - if (!feedUiState.isLoadable && !isAgainRefresh) return - - viewModelScope.launch { - runCatching { - listOf( - async { - novelRepository.fetchNovelFeeds( - novelId = novelId, - lastFeedId = when (feedUiState.feeds.isNotEmpty()) { - true -> feedUiState.feeds.minOf { it.id } - false -> 0 - }, - size = 20, - ) - }, - async { userRepository.fetchUserId() }, - ).awaitAll() - }.onSuccess { responses -> - val feeds = responses[0] as NovelFeedsEntity - userId = responses[1] as Long - - _feedUiState.value = feedUiState.copy( - feeds = feedUiState.feeds + feeds.feeds.map { it.toUi() }, - error = false, - loading = false, - isLoadable = feeds.isLoadable, - ) - - _isRefreshed.value = isAgainRefresh - }.onFailure { - _feedUiState.value = feedUiState.copy( - error = true, - loading = false, - isLoadable = false, - ) - } - } - } - } +@Inject +constructor( + private val novelRepository: NovelRepository, + private val feedRepository: FeedRepository, + private val userRepository: UserRepository, +) : ViewModel() { + private var userId: Long = -1 + private val _feedUiState: MutableLiveData = + MutableLiveData(NovelFeedUiState()) + val feedUiState: LiveData get() = _feedUiState + private val _isRefreshed: MutableLiveData = MutableLiveData(false) + val isRefreshed: LiveData get() = _isRefreshed + private val _isLogin: MutableLiveData = MutableLiveData(false) + val isLogin: LiveData get() = _isLogin + + fun updateFeeds( + novelId: Long, + isAgainRefresh: Boolean = false, + ) { + feedUiState.value?.let { feedUiState -> + if (!feedUiState.isLoadable && !isAgainRefresh) return - fun updateFeedWithDelay( - novelId: Long, - isAgainRefresh: Boolean, - ) { viewModelScope.launch { - delay(UPDATE_TASK_DELAY) - updateFeeds(novelId, isAgainRefresh) + runCatching { + listOf( + async { + novelRepository.fetchNovelFeeds( + novelId = novelId, + lastFeedId = when (feedUiState.feeds.isNotEmpty()) { + true -> feedUiState.feeds.minOf { it.id } + false -> 0 + }, + size = 20, + ) + }, + async { userRepository.fetchUserId() }, + ).awaitAll() + }.onSuccess { responses -> + val feeds = responses[0] as NovelFeedsEntity + userId = responses[1] as Long + + _feedUiState.value = feedUiState.copy( + feeds = feedUiState.feeds + feeds.feeds.map { it.toUi() }, + error = false, + loading = false, + isLoadable = feeds.isLoadable, + ) + + _isRefreshed.value = isAgainRefresh + }.onFailure { + _feedUiState.value = feedUiState.copy( + error = true, + loading = false, + isLoadable = false, + ) + } } } + } - fun updateIsRefreshed(boolean: Boolean) { - _isRefreshed.value = boolean + fun updateFeedWithDelay( + novelId: Long, + isAgainRefresh: Boolean, + ) { + viewModelScope.launch { + delay(UPDATE_TASK_DELAY) + updateFeeds(novelId, isAgainRefresh) } + } - fun updateRefreshedFeeds(novelId: Long) { - feedUiState.value?.let { feedUiState -> - viewModelScope.launch { - runCatching { - novelRepository.fetchNovelFeeds( - novelId = novelId, - lastFeedId = 0, - size = 20, - ) - }.onSuccess { feeds -> - _feedUiState.value = feedUiState.copy( - loading = false, - isLoadable = feeds.isLoadable, - feeds = feeds.feeds.map { it.toUi() }, - ) - }.onFailure { - _feedUiState.value = feedUiState.copy( - loading = false, - error = true, - ) - } - } - } - } + fun updateIsRefreshed(boolean: Boolean) { + _isRefreshed.value = boolean + } - fun updateLike( - selectedFeedId: Long, - isLiked: Boolean, - updatedLikeCount: Int, - ) { - feedUiState.value?.let { feedUiState -> - val selectedFeed = feedUiState.feeds.find { feedModel -> - feedModel.id == selectedFeedId - } ?: throw IllegalArgumentException() - - if (selectedFeed.isLiked == isLiked) return - - viewModelScope.launch { - runCatching { - feedRepository.saveLike(selectedFeed.isLiked, selectedFeedId) - }.onSuccess { - _feedUiState.value = feedUiState.copy( - feeds = feedUiState.feeds.map { feedModel -> - when (feedModel.id == selectedFeedId) { - true -> feedModel.copy( - isLiked = isLiked, - likeCount = updatedLikeCount, - ) - - false -> feedModel - } - }, - ) - }.onFailure { - _feedUiState.value = feedUiState.copy( - loading = false, - error = true, - ) - } + fun updateRefreshedFeeds(novelId: Long) { + feedUiState.value?.let { feedUiState -> + viewModelScope.launch { + runCatching { + novelRepository.fetchNovelFeeds( + novelId = novelId, + lastFeedId = 0, + size = 20, + ) + }.onSuccess { feeds -> + _feedUiState.value = feedUiState.copy( + loading = false, + isLoadable = feeds.isLoadable, + feeds = feeds.feeds.map { it.toUi() }, + ) + }.onFailure { + _feedUiState.value = feedUiState.copy( + loading = false, + error = true, + ) } } } + } + + fun updateLike( + selectedFeedId: Long, + isLiked: Boolean, + updatedLikeCount: Int, + ) { + feedUiState.value?.let { feedUiState -> + val selectedFeed = feedUiState.feeds.find { feedModel -> + feedModel.id == selectedFeedId + } ?: throw IllegalArgumentException() + + if (selectedFeed.isLiked == isLiked) return - fun updateRemovedFeed(feedId: Long) { - feedUiState.value?.let { feedUiState -> - viewModelScope.launch { - _feedUiState.value = feedUiState.copy(loading = true) - val removedFeed = feedUiState.feeds.find { it.id == feedId } - - runCatching { - feedRepository.saveRemovedFeed( - feedId = feedId, - novelId = removedFeed?.novel?.id, - content = removedFeed?.content.orEmpty(), - ) - }.onSuccess { - _feedUiState.value = feedUiState.copy( - loading = false, - feeds = feedUiState.feeds.filter { it.id != feedId }, - ) - }.onFailure { - _feedUiState.value = feedUiState.copy( - loading = false, - error = true, - ) - } + viewModelScope.launch { + runCatching { + feedRepository.saveLike(selectedFeed.isLiked, selectedFeedId) + }.onSuccess { + _feedUiState.value = feedUiState.copy( + feeds = feedUiState.feeds.map { feedModel -> + when (feedModel.id == selectedFeedId) { + true -> feedModel.copy( + isLiked = isLiked, + likeCount = updatedLikeCount, + ) + + false -> feedModel + } + }, + ) + }.onFailure { + _feedUiState.value = feedUiState.copy( + loading = false, + error = true, + ) } } } + } + + fun updateRemovedFeed(feedId: Long) { + feedUiState.value?.let { feedUiState -> + viewModelScope.launch { + _feedUiState.value = feedUiState.copy(loading = true) - fun updateReportedSpoilerFeed(feedId: Long) { - feedUiState.value?.let { feedUiState -> - viewModelScope.launch { - _feedUiState.value = feedUiState.copy(loading = true) - runCatching { - feedRepository.saveSpoilerFeed(feedId) - }.onSuccess { - _feedUiState.value = feedUiState.copy( - loading = false, - feeds = feedUiState.feeds.filter { it.id != feedId }, - ) - }.onFailure { - _feedUiState.value = feedUiState.copy( - loading = false, - error = true, - ) - } + runCatching { + feedRepository.saveRemovedFeed(feedId = feedId) + }.onSuccess { + _feedUiState.value = feedUiState.copy( + loading = false, + feeds = feedUiState.feeds.filter { it.id != feedId }, + ) + }.onFailure { + _feedUiState.value = feedUiState.copy( + loading = false, + error = true, + ) } } } + } - fun updateReportedImpertinenceFeed(feedId: Long) { - feedUiState.value?.let { feedUiState -> - viewModelScope.launch { - _feedUiState.value = feedUiState.copy(loading = true) - runCatching { - feedRepository.saveImpertinenceFeed(feedId) - }.onSuccess { - _feedUiState.value = feedUiState.copy( - loading = false, - feeds = feedUiState.feeds.filter { it.id != feedId }, - ) - }.onFailure { - _feedUiState.value = feedUiState.copy( - loading = false, - error = true, - ) - } + fun updateReportedSpoilerFeed(feedId: Long) { + feedUiState.value?.let { feedUiState -> + viewModelScope.launch { + _feedUiState.value = feedUiState.copy(loading = true) + runCatching { + feedRepository.saveSpoilerFeed(feedId) + }.onSuccess { + _feedUiState.value = feedUiState.copy( + loading = false, + feeds = feedUiState.feeds.filter { it.id != feedId }, + ) + }.onFailure { + _feedUiState.value = feedUiState.copy( + loading = false, + error = true, + ) } } } + } - fun updateLoginStatus() { + fun updateReportedImpertinenceFeed(feedId: Long) { + feedUiState.value?.let { feedUiState -> viewModelScope.launch { + _feedUiState.value = feedUiState.copy(loading = true) runCatching { - userRepository.fetchIsLogin() - }.onSuccess { isLogin -> - // TODO: _isLogin.value = isLogin - _isLogin.value = true + feedRepository.saveImpertinenceFeed(feedId) + }.onSuccess { + _feedUiState.value = feedUiState.copy( + loading = false, + feeds = feedUiState.feeds.filter { it.id != feedId }, + ) }.onFailure { - throw it + _feedUiState.value = feedUiState.copy( + loading = false, + error = true, + ) } } } } + + fun updateLoginStatus() { + viewModelScope.launch { + runCatching { + userRepository.fetchIsLogin() + }.onSuccess { isLogin -> + // TODO: _isLogin.value = isLogin + _isLogin.value = true + }.onFailure { + throw it + } + } + } +} diff --git a/app/src/main/java/com/into/websoso/ui/novelRating/NovelRatingViewModel.kt b/app/src/main/java/com/into/websoso/ui/novelRating/NovelRatingViewModel.kt index db8bdf4bb..71f6fa8fa 100644 --- a/app/src/main/java/com/into/websoso/ui/novelRating/NovelRatingViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/novelRating/NovelRatingViewModel.kt @@ -25,355 +25,351 @@ import javax.inject.Inject @HiltViewModel class NovelRatingViewModel - @Inject - constructor( - savedStateHandle: SavedStateHandle, - private val userNovelRepository: UserNovelRepository, - private val keywordRepository: KeywordRepository, - ) : ViewModel() { - private val novel: NovelDetailModel? = savedStateHandle["NOVEL"] - private val feeds: ArrayList? = savedStateHandle["FEEDS"] - private val isInterested: Boolean? = savedStateHandle["IS_INTEREST"] - private val _uiState = MutableLiveData(NovelRatingUiState()) - val uiState: LiveData get() = _uiState - private val ratingDateManager = RatingDateManager() +@Inject +constructor( + savedStateHandle: SavedStateHandle, + private val userNovelRepository: UserNovelRepository, + private val keywordRepository: KeywordRepository, +) : ViewModel() { + private val novel: NovelDetailModel? = savedStateHandle["NOVEL"] + private val _uiState = MutableLiveData(NovelRatingUiState()) + val uiState: LiveData get() = _uiState + private val ratingDateManager = RatingDateManager() - fun updateNovelRating(isInterest: Boolean) { - viewModelScope.launch { - runCatching { - _uiState.value = uiState.value?.copy(loading = true) - userNovelRepository.fetchNovelRating(novel?.novel?.novelId ?: 0) - }.onSuccess { novelRatingEntity -> - handleSuccessfulFetchNovelRating(novelRatingEntity, isInterest) - }.onFailure { - _uiState.value = - uiState.value?.copy( - loading = false, - isFetchError = true, - ) - } - } - } - - private fun handleSuccessfulFetchNovelRating( - novelRatingEntity: NovelRatingEntity, - isInterest: Boolean, - ) { - val novelRatingModel = novelRatingEntity.toUi() - val isEditingStartDate = - ratingDateManager.updateIsEditingStartDate(novelRatingModel.uiReadStatus) - val dayMaxValue = ratingDateManager.updateDayMaxValue( - novelRatingModel.ratingDateModel, - isEditingStartDate, - ) - _uiState.value = uiState.value?.copy( - novelRatingModel = novelRatingModel, - keywordsModel = NovelRatingKeywordsModel( - currentSelectedKeywords = novelRatingModel.userKeywords, - ), - isEditingStartDate = isEditingStartDate, - isAlreadyRated = novelRatingEntity.readStatus != null || isInterest, - maxDayValue = dayMaxValue, - loading = false, - ) - updateKeywordCategories() - } - - fun updateKeywordCategories(keyword: String? = null) { - viewModelScope.launch { - runCatching { - if (keyword == null && - uiState.value - ?.keywordsModel - ?.categories - ?.isNotEmpty() == true - ) { - return@launch - } - keywordRepository.fetchKeywords(keyword) - }.onSuccess { categories -> - handleSuccessfulFetchKeywordCategories( - keyword, - categories.categories.map { it.toUi() }, - ) - }.onFailure { - _uiState.value = uiState.value?.copy( + fun updateNovelRating(isInterest: Boolean) { + viewModelScope.launch { + runCatching { + _uiState.value = uiState.value?.copy(loading = true) + userNovelRepository.fetchNovelRating(novel?.novel?.novelId ?: 0) + }.onSuccess { novelRatingEntity -> + handleSuccessfulFetchNovelRating(novelRatingEntity, isInterest) + }.onFailure { + _uiState.value = + uiState.value?.copy( loading = false, isFetchError = true, ) - } } } + } - private fun handleSuccessfulFetchKeywordCategories( - keyword: String?, - categories: List, - ) { - val selectedKeywords = uiState.value?.keywordsModel?.currentSelectedKeywords ?: emptySet() - val updatedCategories = categories.map { it }.map { - it.copy( - keywords = it.keywords.map { keyword -> - keyword.copy(isSelected = selectedKeywords.contains(keyword)) - }, + private fun handleSuccessfulFetchNovelRating( + novelRatingEntity: NovelRatingEntity, + isInterest: Boolean, + ) { + val novelRatingModel = novelRatingEntity.toUi() + val isEditingStartDate = + ratingDateManager.updateIsEditingStartDate(novelRatingModel.uiReadStatus) + val dayMaxValue = ratingDateManager.updateDayMaxValue( + novelRatingModel.ratingDateModel, + isEditingStartDate, + ) + _uiState.value = uiState.value?.copy( + novelRatingModel = novelRatingModel, + keywordsModel = NovelRatingKeywordsModel( + currentSelectedKeywords = novelRatingModel.userKeywords, + ), + isEditingStartDate = isEditingStartDate, + isAlreadyRated = novelRatingEntity.readStatus != null || isInterest, + maxDayValue = dayMaxValue, + loading = false, + ) + updateKeywordCategories() + } + + fun updateKeywordCategories(keyword: String? = null) { + viewModelScope.launch { + runCatching { + if (keyword == null && + uiState.value + ?.keywordsModel + ?.categories + ?.isNotEmpty() == true + ) { + return@launch + } + keywordRepository.fetchKeywords(keyword) + }.onSuccess { categories -> + handleSuccessfulFetchKeywordCategories( + keyword, + categories.categories.map { it.toUi() }, + ) + }.onFailure { + _uiState.value = uiState.value?.copy( + loading = false, + isFetchError = true, ) } - when (keyword == null) { - true -> updateDefaultKeywords(updatedCategories, selectedKeywords) + } + } - false -> updateSearchResultKeywords(updatedCategories) - } + private fun handleSuccessfulFetchKeywordCategories( + keyword: String?, + categories: List, + ) { + val selectedKeywords = uiState.value?.keywordsModel?.currentSelectedKeywords ?: emptySet() + val updatedCategories = categories.map { it }.map { + it.copy( + keywords = it.keywords.map { keyword -> + keyword.copy(isSelected = selectedKeywords.contains(keyword)) + }, + ) } + when (keyword == null) { + true -> updateDefaultKeywords(updatedCategories, selectedKeywords) - private fun updateSearchResultKeywords(updatedCategories: List) { - _uiState.value = uiState.value?.let { uiState -> - uiState.copy( - keywordsModel = uiState.keywordsModel.copy( - searchResultKeywords = updatedCategories.flatMap { it.keywords }, - isInitialSearchKeyword = false, - isSearchResultKeywordsEmpty = updatedCategories - .flatMap { it.keywords } - .isEmpty(), - ), - ) - } + false -> updateSearchResultKeywords(updatedCategories) } + } - private fun updateDefaultKeywords( - updatedCategories: List, - selectedKeywords: Set, - ) { - _uiState.value = uiState.value?.copy( - keywordsModel = NovelRatingKeywordsModel( - categories = updatedCategories, - currentSelectedKeywords = selectedKeywords, + private fun updateSearchResultKeywords(updatedCategories: List) { + _uiState.value = uiState.value?.let { uiState -> + uiState.copy( + keywordsModel = uiState.keywordsModel.copy( + searchResultKeywords = updatedCategories.flatMap { it.keywords }, + isInitialSearchKeyword = false, + isSearchResultKeywordsEmpty = updatedCategories + .flatMap { it.keywords } + .isEmpty(), ), - loading = false, ) } + } - fun updatePreviousDate() { - uiState.value?.let { currentState -> - val updatedRatingDateModel = currentState.novelRatingModel.ratingDateModel.copy( - previousStartDate = currentState.novelRatingModel.ratingDateModel.currentStartDate, - previousEndDate = currentState.novelRatingModel.ratingDateModel.currentEndDate, - ) + private fun updateDefaultKeywords( + updatedCategories: List, + selectedKeywords: Set, + ) { + _uiState.value = uiState.value?.copy( + keywordsModel = NovelRatingKeywordsModel( + categories = updatedCategories, + currentSelectedKeywords = selectedKeywords, + ), + loading = false, + ) + } - val updatedNovelRatingModel = currentState.novelRatingModel.copy( - ratingDateModel = updatedRatingDateModel, - ) + fun updatePreviousDate() { + uiState.value?.let { currentState -> + val updatedRatingDateModel = currentState.novelRatingModel.ratingDateModel.copy( + previousStartDate = currentState.novelRatingModel.ratingDateModel.currentStartDate, + previousEndDate = currentState.novelRatingModel.ratingDateModel.currentEndDate, + ) - _uiState.value = currentState.copy(novelRatingModel = updatedNovelRatingModel) - } - } + val updatedNovelRatingModel = currentState.novelRatingModel.copy( + ratingDateModel = updatedRatingDateModel, + ) - fun updateReadStatus(readStatus: ReadStatus) { - if (readStatus == ReadStatus.NONE) return - uiState.value?.let { uiState -> - val updatedModel = - ratingDateManager.updateReadStatus(uiState.novelRatingModel, readStatus) - _uiState.value = uiState.copy( - novelRatingModel = updatedModel, - isEditingStartDate = ratingDateManager.updateIsEditingStartDate(readStatus), - ) - } + _uiState.value = currentState.copy(novelRatingModel = updatedNovelRatingModel) } + } - fun toggleEditingStartDate(boolean: Boolean) { - uiState.value?.let { uiState -> - _uiState.value = uiState.copy( - isEditingStartDate = boolean, - ) - } + fun updateReadStatus(readStatus: ReadStatus) { + if (readStatus == ReadStatus.NONE) return + uiState.value?.let { uiState -> + val updatedModel = + ratingDateManager.updateReadStatus(uiState.novelRatingModel, readStatus) + _uiState.value = uiState.copy( + novelRatingModel = updatedModel, + isEditingStartDate = ratingDateManager.updateIsEditingStartDate(readStatus), + ) } + } - fun updateCurrentDate(date: Triple) { - uiState.value?.let { uiState -> - val updatedModel = ratingDateManager.updateCurrentDate( - uiState.novelRatingModel, - date, - uiState.isEditingStartDate, - ) - val maxDayValue = ratingDateManager.updateDayMaxValue( - updatedModel, - uiState.isEditingStartDate, - ) - _uiState.value = uiState.copy( - novelRatingModel = uiState.novelRatingModel.copy(ratingDateModel = updatedModel), - maxDayValue = maxDayValue, - ) - } + fun toggleEditingStartDate(boolean: Boolean) { + uiState.value?.let { uiState -> + _uiState.value = uiState.copy( + isEditingStartDate = boolean, + ) } + } - fun clearCurrentDate() { - uiState.value?.let { uiState -> - val updatedModel = ratingDateManager.clearCurrentDate( - uiState.novelRatingModel.ratingDateModel, - ) - _uiState.value = uiState.copy( - novelRatingModel = uiState.novelRatingModel.copy(ratingDateModel = updatedModel), - ) - } + fun updateCurrentDate(date: Triple) { + uiState.value?.let { uiState -> + val updatedModel = ratingDateManager.updateCurrentDate( + uiState.novelRatingModel, + date, + uiState.isEditingStartDate, + ) + val maxDayValue = ratingDateManager.updateDayMaxValue( + updatedModel, + uiState.isEditingStartDate, + ) + _uiState.value = uiState.copy( + novelRatingModel = uiState.novelRatingModel.copy(ratingDateModel = updatedModel), + maxDayValue = maxDayValue, + ) } + } - fun cancelDateEdit() { - uiState.value?.let { uiState -> - val updatedModel = ratingDateManager.cancelDateEdit( - uiState.novelRatingModel.ratingDateModel, - ) - _uiState.value = uiState.copy( - novelRatingModel = uiState.novelRatingModel.copy(ratingDateModel = updatedModel), - ) - } + fun clearCurrentDate() { + uiState.value?.let { uiState -> + val updatedModel = ratingDateManager.clearCurrentDate( + uiState.novelRatingModel.ratingDateModel, + ) + _uiState.value = uiState.copy( + novelRatingModel = uiState.novelRatingModel.copy(ratingDateModel = updatedModel), + ) } + } - fun updateNotNullDate() { - uiState.value?.let { uiState -> - val updatedModel = ratingDateManager.getNotNullDate(uiState.novelRatingModel) - _uiState.value = uiState.copy( - novelRatingModel = uiState.novelRatingModel.copy(ratingDateModel = updatedModel), - ) - } + fun cancelDateEdit() { + uiState.value?.let { uiState -> + val updatedModel = ratingDateManager.cancelDateEdit( + uiState.novelRatingModel.ratingDateModel, + ) + _uiState.value = uiState.copy( + novelRatingModel = uiState.novelRatingModel.copy(ratingDateModel = updatedModel), + ) } + } - fun updateCharmPoints(charmPoint: CharmPoint) { - uiState.value?.let { uiState -> - val charmPoints = uiState.novelRatingModel.charmPoints.toMutableList() - when (charmPoints.contains(charmPoint)) { - true -> charmPoints.remove(charmPoint) - false -> charmPoints.add(charmPoint) - } - val updatedNovelRatingModel = - uiState.novelRatingModel.copy( - charmPoints = charmPoints.toList(), - isCharmPointExceed = charmPoints.size > MAX_CHARM_POINT_COUNT, - ) - _uiState.value = uiState.copy(novelRatingModel = updatedNovelRatingModel) - } + fun updateNotNullDate() { + uiState.value?.let { uiState -> + val updatedModel = ratingDateManager.getNotNullDate(uiState.novelRatingModel) + _uiState.value = uiState.copy( + novelRatingModel = uiState.novelRatingModel.copy(ratingDateModel = updatedModel), + ) } + } - fun updateSelectedKeywords( - keyword: KeywordModel, - isSelected: Boolean, - ) { - uiState.value?.let { uiState -> - _uiState.value = uiState.copy( - keywordsModel = uiState.keywordsModel.updateSelectedKeywords(keyword, isSelected), - ) + fun updateCharmPoints(charmPoint: CharmPoint) { + uiState.value?.let { uiState -> + val charmPoints = uiState.novelRatingModel.charmPoints.toMutableList() + when (charmPoints.contains(charmPoint)) { + true -> charmPoints.remove(charmPoint) + false -> charmPoints.add(charmPoint) } + val updatedNovelRatingModel = + uiState.novelRatingModel.copy( + charmPoints = charmPoints.toList(), + isCharmPointExceed = charmPoints.size > MAX_CHARM_POINT_COUNT, + ) + _uiState.value = uiState.copy(novelRatingModel = updatedNovelRatingModel) } + } - fun saveSelectedKeywords() { - uiState.value?.let { uiState -> - val selectedKeywords = uiState.keywordsModel.currentSelectedKeywords - val updatedRatingModel = uiState.novelRatingModel.copy(userKeywords = selectedKeywords) - _uiState.value = uiState.copy(novelRatingModel = updatedRatingModel) - } + fun updateSelectedKeywords( + keyword: KeywordModel, + isSelected: Boolean, + ) { + uiState.value?.let { uiState -> + _uiState.value = uiState.copy( + keywordsModel = uiState.keywordsModel.updateSelectedKeywords(keyword, isSelected), + ) } + } - fun clearEditingKeyword() { - uiState.value?.let { uiState -> - _uiState.value = uiState.copy( - novelRatingModel = uiState.novelRatingModel.copy(userKeywords = setOf()), - keywordsModel = uiState.keywordsModel.copy( - categories = uiState.keywordsModel.categories.map { category -> - category.copy( - keywords = category.keywords.map { keyword -> - keyword.copy(isSelected = false) - }, - ) - }, - currentSelectedKeywords = setOf(), - ), - ) - } + fun saveSelectedKeywords() { + uiState.value?.let { uiState -> + val selectedKeywords = uiState.keywordsModel.currentSelectedKeywords + val updatedRatingModel = uiState.novelRatingModel.copy(userKeywords = selectedKeywords) + _uiState.value = uiState.copy(novelRatingModel = updatedRatingModel) } + } - fun cancelEditingKeyword() { - uiState.value?.let { uiState -> - _uiState.value = uiState.copy( - keywordsModel = uiState.keywordsModel.copy( - currentSelectedKeywords = uiState.novelRatingModel.userKeywords, - ), - ) - } + fun clearEditingKeyword() { + uiState.value?.let { uiState -> + _uiState.value = uiState.copy( + novelRatingModel = uiState.novelRatingModel.copy(userKeywords = setOf()), + keywordsModel = uiState.keywordsModel.copy( + categories = uiState.keywordsModel.categories.map { category -> + category.copy( + keywords = category.keywords.map { keyword -> + keyword.copy(isSelected = false) + }, + ) + }, + currentSelectedKeywords = setOf(), + ), + ) } + } - fun updateUserNovelRating(novelRating: Float) { - viewModelScope.launch { - val ratingModel = uiState.value?.novelRatingModel - runCatching { - userNovelRepository.saveNovelRating( - isInterested = isInterested, - novelRatingEntity = NovelRatingEntity( - userNovelId = novel?.userNovel?.userNovelId, - novelId = novel?.novel?.novelId ?: 0, - userNovelRating = novelRating, - novelRating = novel?.userRating?.novelRating ?: 0.0f, - novelTitle = novel?.novel?.novelTitle, - novelImage = novel?.novel?.novelImage, - startDate = ratingModel - ?.ratingDateModel - ?.currentStartDate - ?.toFormattedDate(), - endDate = ratingModel - ?.ratingDateModel - ?.currentEndDate - ?.toFormattedDate(), - charmPoints = ratingModel - ?.charmPoints - ?.map { it.value } - ?: emptyList(), - userKeywords = ratingModel - ?.userKeywords - ?.map { it.toData() } - ?: emptyList(), - readStatus = ratingModel?.uiReadStatus?.name, - ), - isAlreadyRated = uiState.value?.isAlreadyRated ?: false, - feeds = feeds?.toList() ?: emptyList(), - ) - }.onSuccess { - _uiState.value = uiState.value?.copy(isSaveSuccess = true) - }.onFailure { - _uiState.value = uiState.value?.copy(isSaveError = true) - } - } + fun cancelEditingKeyword() { + uiState.value?.let { uiState -> + _uiState.value = uiState.copy( + keywordsModel = uiState.keywordsModel.copy( + currentSelectedKeywords = uiState.novelRatingModel.userKeywords, + ), + ) } + } - fun updateRatingValue(rating: Float) { - uiState.value?.let { uiState -> - _uiState.value = uiState.copy( - novelRatingModel = uiState.novelRatingModel.copy( - userNovelRating = rating, + fun updateUserNovelRating(novelRating: Float) { + viewModelScope.launch { + val ratingModel = uiState.value?.novelRatingModel + runCatching { + userNovelRepository.saveNovelRating( + novelRatingEntity = NovelRatingEntity( + userNovelId = novel?.userNovel?.userNovelId, + novelId = novel?.novel?.novelId ?: 0, + userNovelRating = novelRating, + novelRating = novel?.userRating?.novelRating ?: 0.0f, + novelTitle = novel?.novel?.novelTitle, + novelImage = novel?.novel?.novelImage, + startDate = ratingModel + ?.ratingDateModel + ?.currentStartDate + ?.toFormattedDate(), + endDate = ratingModel + ?.ratingDateModel + ?.currentEndDate + ?.toFormattedDate(), + charmPoints = ratingModel + ?.charmPoints + ?.map { it.value } + ?: emptyList(), + userKeywords = ratingModel + ?.userKeywords + ?.map { it.toData() } + ?: emptyList(), + readStatus = ratingModel?.uiReadStatus?.name, ), + isAlreadyRated = uiState.value?.isAlreadyRated ?: false, ) + }.onSuccess { + _uiState.value = uiState.value?.copy(isSaveSuccess = true) + }.onFailure { + _uiState.value = uiState.value?.copy(isSaveError = true) } } + } - fun updateIsSearchKeywordProceeding(isProceeding: Boolean) { - uiState.value?.let { uiState -> - _uiState.value = uiState.copy( - keywordsModel = uiState.keywordsModel.copy( - isSearchKeywordProceeding = isProceeding, - ), - ) - } + fun updateRatingValue(rating: Float) { + uiState.value?.let { uiState -> + _uiState.value = uiState.copy( + novelRatingModel = uiState.novelRatingModel.copy( + userNovelRating = rating, + ), + ) } + } - fun initSearchKeyword() { - uiState.value?.let { uiState -> - _uiState.value = uiState.copy( - keywordsModel = uiState.keywordsModel.copy( - searchResultKeywords = emptyList(), - isSearchKeywordProceeding = false, - isInitialSearchKeyword = true, - isSearchResultKeywordsEmpty = false, - ), - ) - } + fun updateIsSearchKeywordProceeding(isProceeding: Boolean) { + uiState.value?.let { uiState -> + _uiState.value = uiState.copy( + keywordsModel = uiState.keywordsModel.copy( + isSearchKeywordProceeding = isProceeding, + ), + ) } + } - companion object { - private const val MAX_CHARM_POINT_COUNT = 3 + fun initSearchKeyword() { + uiState.value?.let { uiState -> + _uiState.value = uiState.copy( + keywordsModel = uiState.keywordsModel.copy( + searchResultKeywords = emptyList(), + isSearchKeywordProceeding = false, + isInitialSearchKeyword = true, + isSearchResultKeywordsEmpty = false, + ), + ) } } + + companion object { + private const val MAX_CHARM_POINT_COUNT = 3 + } +} diff --git a/app/src/main/java/com/into/websoso/ui/withdraw/second/WithdrawSecondViewModel.kt b/app/src/main/java/com/into/websoso/ui/withdraw/second/WithdrawSecondViewModel.kt index fc06d95f2..5482feb33 100644 --- a/app/src/main/java/com/into/websoso/ui/withdraw/second/WithdrawSecondViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/withdraw/second/WithdrawSecondViewModel.kt @@ -16,87 +16,86 @@ import javax.inject.Inject @HiltViewModel class WithdrawSecondViewModel - @Inject - constructor( - private val accountRepository: AccountRepository, - private val pushMessageRepository: PushMessageRepository, - private val userRepository: UserRepository, - private val libraryRepository: MyLibraryRepository, - private val filterRepository: MyLibraryFilterRepository, - ) : ViewModel() { - private val _withdrawReason: MutableLiveData = MutableLiveData("") - val withdrawReason: LiveData get() = _withdrawReason - - private val _isWithdrawAgreementChecked: MutableLiveData = MutableLiveData(false) - val isWithdrawAgreementChecked: LiveData get() = _isWithdrawAgreementChecked - - private val _isWithdrawButtonEnabled: MediatorLiveData = MediatorLiveData(false) - val isWithdrawButtonEnabled: LiveData get() = _isWithdrawButtonEnabled - - private val _isWithDrawSuccess: MutableLiveData = MutableLiveData(false) - val isWithDrawSuccess: LiveData get() = _isWithDrawSuccess - - val withdrawEtcReason: MutableLiveData = MutableLiveData() - - val withdrawEtcReasonCount: MediatorLiveData = MediatorLiveData() - - init { - withdrawEtcReasonCount.addSource(withdrawEtcReason) { reason -> - withdrawEtcReasonCount.value = reason.length - } - - _isWithdrawButtonEnabled.addSource(withdrawReason) { - _isWithdrawButtonEnabled.value = isEnabled() - } - _isWithdrawButtonEnabled.addSource(isWithdrawAgreementChecked) { - _isWithdrawButtonEnabled.value = isEnabled() - } - _isWithdrawButtonEnabled.addSource(withdrawEtcReason) { - _isWithdrawButtonEnabled.value = isEnabled() - } - } +@Inject +constructor( + private val accountRepository: AccountRepository, + private val pushMessageRepository: PushMessageRepository, + private val userRepository: UserRepository, + private val libraryRepository: MyLibraryRepository, + private val filterRepository: MyLibraryFilterRepository, +) : ViewModel() { + private val _withdrawReason: MutableLiveData = MutableLiveData("") + val withdrawReason: LiveData get() = _withdrawReason - private fun isEnabled(): Boolean { - val isReasonNotBlank: Boolean = _withdrawReason.value?.isNotBlank() == true - val isWithdrawAgreement: Boolean = _isWithdrawAgreementChecked.value == true - val isEtcReasonValid = - _withdrawReason.value == ETC_INPUT_REASON && withdrawEtcReason.value?.isNotBlank() == true - - return when { - _withdrawReason.value == ETC_INPUT_REASON -> isEtcReasonValid && isWithdrawAgreement - isReasonNotBlank && isWithdrawAgreement -> true - else -> false - } - } + private val _isWithdrawAgreementChecked: MutableLiveData = MutableLiveData(false) + val isWithdrawAgreementChecked: LiveData get() = _isWithdrawAgreementChecked + + private val _isWithdrawButtonEnabled: MediatorLiveData = MediatorLiveData(false) + val isWithdrawButtonEnabled: LiveData get() = _isWithdrawButtonEnabled + + private val _isWithDrawSuccess: MutableLiveData = MutableLiveData(false) + val isWithDrawSuccess: LiveData get() = _isWithDrawSuccess + + val withdrawEtcReason: MutableLiveData = MutableLiveData() - fun updateWithdrawReason(reason: String) { - _withdrawReason.value = reason + val withdrawEtcReasonCount: MediatorLiveData = MediatorLiveData() + + init { + withdrawEtcReasonCount.addSource(withdrawEtcReason) { reason -> + withdrawEtcReasonCount.value = reason.length } - fun updateIsWithdrawAgreementChecked() { - _isWithdrawAgreementChecked.value = isWithdrawAgreementChecked.value?.not() + _isWithdrawButtonEnabled.addSource(withdrawReason) { + _isWithdrawButtonEnabled.value = isEnabled() + } + _isWithdrawButtonEnabled.addSource(isWithdrawAgreementChecked) { + _isWithdrawButtonEnabled.value = isEnabled() } + _isWithdrawButtonEnabled.addSource(withdrawEtcReason) { + _isWithdrawButtonEnabled.value = isEnabled() + } + } - fun withdraw() { - viewModelScope.launch { - val reason = - if (withdrawReason.value == ETC_INPUT_REASON) withdrawEtcReason.value else withdrawReason.value - - accountRepository - .deleteAccount(reason.orEmpty()) - .onSuccess { - _isWithDrawSuccess.value = true - userRepository.removeTermsAgreementChecked() - pushMessageRepository.clearFCMToken() - libraryRepository.deleteAllNovels() - filterRepository.deleteLibraryFilter() - }.onFailure { - _isWithDrawSuccess.value = false - } - } + private fun isEnabled(): Boolean { + val isReasonNotBlank: Boolean = _withdrawReason.value?.isNotBlank() == true + val isWithdrawAgreement: Boolean = _isWithdrawAgreementChecked.value == true + val isEtcReasonValid = + _withdrawReason.value == ETC_INPUT_REASON && withdrawEtcReason.value?.isNotBlank() == true + + return when { + _withdrawReason.value == ETC_INPUT_REASON -> isEtcReasonValid && isWithdrawAgreement + isReasonNotBlank && isWithdrawAgreement -> true + else -> false } + } + + fun updateWithdrawReason(reason: String) { + _withdrawReason.value = reason + } - companion object { - private const val ETC_INPUT_REASON = "직접입력" + fun updateIsWithdrawAgreementChecked() { + _isWithdrawAgreementChecked.value = isWithdrawAgreementChecked.value?.not() + } + + fun withdraw() { + viewModelScope.launch { + val reason = + if (withdrawReason.value == ETC_INPUT_REASON) withdrawEtcReason.value else withdrawReason.value + + accountRepository + .deleteAccount(reason.orEmpty()) + .onSuccess { + _isWithDrawSuccess.value = true + userRepository.removeTermsAgreementChecked() + pushMessageRepository.clearFCMToken() + filterRepository.deleteLibraryFilter() + }.onFailure { + _isWithDrawSuccess.value = false + } } } + + companion object { + private const val ETC_INPUT_REASON = "직접입력" + } +} diff --git a/core/database/.gitignore b/core/database/.gitignore deleted file mode 100644 index 42afabfd2..000000000 --- a/core/database/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts deleted file mode 100644 index fbafb361f..000000000 --- a/core/database/build.gradle.kts +++ /dev/null @@ -1,23 +0,0 @@ -import com.into.websoso.setNamespace - -plugins { - id("websoso.android.library") -} - -android { - setNamespace("core.database") -} - -dependencies { - // 데이터 레이어 의존성 - implementation(projects.data.library) - - // 페이징 관련 의존성 - implementation(libs.paging.runtime) - - // 데이터베이스 관련 의존성 - implementation(libs.room.paging) - implementation(libs.room.ktx) - implementation(libs.room.runtime) - kapt(libs.room.compiler) -} diff --git a/core/database/consumer-rules.pro b/core/database/consumer-rules.pro deleted file mode 100644 index e69de29bb..000000000 diff --git a/core/database/proguard-rules.pro b/core/database/proguard-rules.pro deleted file mode 100644 index 481bb4348..000000000 --- a/core/database/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/core/database/src/main/AndroidManifest.xml b/core/database/src/main/AndroidManifest.xml deleted file mode 100644 index e10007615..000000000 --- a/core/database/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/core/database/src/main/java/com/into/websoso/core/database/Converters.kt b/core/database/src/main/java/com/into/websoso/core/database/Converters.kt deleted file mode 100644 index c4b0f8e80..000000000 --- a/core/database/src/main/java/com/into/websoso/core/database/Converters.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.into.websoso.core.database - -import androidx.room.TypeConverter - -internal class Converters { - @TypeConverter - fun fromString(value: String): List = if (value.isEmpty()) emptyList() else value.split(",") - - @TypeConverter - fun listToString(list: List): String = list.joinToString(",") -} diff --git a/core/database/src/main/java/com/into/websoso/core/database/WebsosoDatabase.kt b/core/database/src/main/java/com/into/websoso/core/database/WebsosoDatabase.kt deleted file mode 100644 index 7da90bf67..000000000 --- a/core/database/src/main/java/com/into/websoso/core/database/WebsosoDatabase.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.into.websoso.core.database - -import androidx.room.Database -import androidx.room.RoomDatabase -import androidx.room.TypeConverters -import com.into.websoso.core.database.datasource.library.dao.NovelDao -import com.into.websoso.core.database.datasource.library.entity.InDatabaseNovelEntity - -@Database( - entities = [InDatabaseNovelEntity::class], - version = 5, - exportSchema = false, -) -@TypeConverters(Converters::class) -internal abstract class WebsosoDatabase : RoomDatabase() { - internal abstract fun novelDao(): NovelDao -} diff --git a/core/database/src/main/java/com/into/websoso/core/database/datasource/library/DefaultLibraryLocalDataSource.kt b/core/database/src/main/java/com/into/websoso/core/database/datasource/library/DefaultLibraryLocalDataSource.kt deleted file mode 100644 index 08b8238fd..000000000 --- a/core/database/src/main/java/com/into/websoso/core/database/datasource/library/DefaultLibraryLocalDataSource.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.into.websoso.core.database.datasource.library - -import androidx.paging.PagingSource -import androidx.room.Transaction -import com.into.websoso.core.database.datasource.library.dao.NovelDao -import com.into.websoso.core.database.datasource.library.entity.InDatabaseNovelEntity -import com.into.websoso.core.database.datasource.library.mapper.toData -import com.into.websoso.core.database.datasource.library.mapper.toNovelDatabase -import com.into.websoso.core.database.datasource.library.paging.mapValue -import com.into.websoso.data.library.datasource.LibraryLocalDataSource -import com.into.websoso.data.library.model.NovelEntity -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Inject -import javax.inject.Singleton - -internal class DefaultLibraryLocalDataSource - @Inject - constructor( - private val novelDao: NovelDao, - ) : LibraryLocalDataSource { - @Transaction - override suspend fun insertNovels(novels: List) { - novelDao.apply { - val offset = selectNovelsCount() - val dbEntities = novels.mapIndexed { index, novelEntity -> - novelEntity.toNovelDatabase(offset + index) - } - insertNovels(dbEntities) - } - } - - @Transaction - override suspend fun insertNovel(novel: NovelEntity) { - novelDao.apply { - val novelIndex = selectNovelByUserNovelId(novel.userNovelId)?.sortIndex ?: 0 - insertNovel(novel.toNovelDatabase(novelIndex)) - } - } - - override suspend fun selectLastNovel(): NovelEntity? = novelDao.selectLastNovel()?.toData() - - override fun selectAllNovels(): PagingSource = novelDao.selectAllNovels().mapValue(InDatabaseNovelEntity::toData) - - override suspend fun selectNovelByUserNovelId(userNovelId: Long): NovelEntity? = - novelDao.selectNovelByUserNovelId(userNovelId)?.toData() - - override suspend fun selectNovelByNovelId(novelId: Long): NovelEntity? = novelDao.selectNovelByNovelId(novelId)?.toData() - - override suspend fun selectAllNovelsCount(): Int = novelDao.selectNovelsCount() - - override suspend fun deleteAllNovels() { - novelDao.deleteAllNovels() - } - - override suspend fun deleteNovel(novelId: Long) { - novelDao.deleteNovel(novelId) - } - } - -@Module -@InstallIn(SingletonComponent::class) -internal interface LibraryDataSourceModule { - @Binds - @Singleton - fun bindLibraryDataSource(defaultLibraryLocalDataSource: DefaultLibraryLocalDataSource): LibraryLocalDataSource -} diff --git a/core/database/src/main/java/com/into/websoso/core/database/datasource/library/dao/NovelDao.kt b/core/database/src/main/java/com/into/websoso/core/database/datasource/library/dao/NovelDao.kt deleted file mode 100644 index 9b93e31c8..000000000 --- a/core/database/src/main/java/com/into/websoso/core/database/datasource/library/dao/NovelDao.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.into.websoso.core.database.datasource.library.dao - -import androidx.paging.PagingSource -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import com.into.websoso.core.database.WebsosoDatabase -import com.into.websoso.core.database.datasource.library.entity.InDatabaseNovelEntity -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Dao -internal interface NovelDao { - @Query("SELECT * FROM novels ORDER BY sortIndex ASC") - fun selectAllNovels(): PagingSource - - @Query("SELECT * FROM novels ORDER BY sortIndex DESC LIMIT 1") - suspend fun selectLastNovel(): InDatabaseNovelEntity? - - @Query("SELECT COUNT(*) FROM novels") - suspend fun selectNovelsCount(): Int - - @Query("SELECT * FROM novels WHERE userNovelId = :userNovelId LIMIT 1") - suspend fun selectNovelByUserNovelId(userNovelId: Long): InDatabaseNovelEntity? - - @Query("SELECT * FROM novels WHERE novelId = :novelId LIMIT 1") - suspend fun selectNovelByNovelId(novelId: Long): InDatabaseNovelEntity? - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertNovels(novels: List) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertNovel(novel: InDatabaseNovelEntity) - - @Query("DELETE FROM novels") - suspend fun deleteAllNovels() - - @Query("DELETE FROM novels WHERE novelId = :novelId") - suspend fun deleteNovel(novelId: Long) -} - -@Module -@InstallIn(SingletonComponent::class) -internal object NovelDaoModule { - @Provides - @Singleton - internal fun provideNovelDao(database: WebsosoDatabase): NovelDao = database.novelDao() -} diff --git a/core/database/src/main/java/com/into/websoso/core/database/datasource/library/entity/InDatabaseNovelEntity.kt b/core/database/src/main/java/com/into/websoso/core/database/datasource/library/entity/InDatabaseNovelEntity.kt deleted file mode 100644 index 95ec6b162..000000000 --- a/core/database/src/main/java/com/into/websoso/core/database/datasource/library/entity/InDatabaseNovelEntity.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.into.websoso.core.database.datasource.library.entity - -import androidx.room.Entity -import androidx.room.PrimaryKey - -@Entity(tableName = "novels") -internal data class InDatabaseNovelEntity( - @PrimaryKey val userNovelId: Long, - val novelId: Long, - val title: String, - val sortIndex: Int, - val novelImage: String, - val novelRating: Float, - val readStatus: String, - val isInterest: Boolean, - val userNovelRating: Float, - val attractivePoints: List, - val startDate: String, - val endDate: String, - val keywords: List, - val myFeeds: List, -) diff --git a/core/database/src/main/java/com/into/websoso/core/database/datasource/library/mapper/NovelMapper.kt b/core/database/src/main/java/com/into/websoso/core/database/datasource/library/mapper/NovelMapper.kt deleted file mode 100644 index 9abe1a186..000000000 --- a/core/database/src/main/java/com/into/websoso/core/database/datasource/library/mapper/NovelMapper.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.into.websoso.core.database.datasource.library.mapper - -import com.into.websoso.core.database.datasource.library.entity.InDatabaseNovelEntity -import com.into.websoso.data.library.model.NovelEntity - -internal fun NovelEntity.toNovelDatabase(index: Int): InDatabaseNovelEntity = - InDatabaseNovelEntity( - sortIndex = index, - userNovelId = userNovelId, - novelId = novelId, - title = title, - novelImage = novelImage, - novelRating = novelRating, - readStatus = readStatus, - isInterest = isInterest, - userNovelRating = userNovelRating, - attractivePoints = attractivePoints, - startDate = startDate, - endDate = endDate, - keywords = keywords, - myFeeds = myFeeds, - ) - -internal fun InDatabaseNovelEntity.toData(): NovelEntity = - NovelEntity( - userNovelId = userNovelId, - novelId = novelId, - title = title, - novelImage = novelImage, - novelRating = novelRating, - readStatus = readStatus, - isInterest = isInterest, - userNovelRating = userNovelRating, - attractivePoints = attractivePoints, - startDate = startDate, - endDate = endDate, - keywords = keywords, - myFeeds = myFeeds, - ) diff --git a/core/database/src/main/java/com/into/websoso/core/database/datasource/library/paging/MapValuePagingSource.kt b/core/database/src/main/java/com/into/websoso/core/database/datasource/library/paging/MapValuePagingSource.kt deleted file mode 100644 index 5917dba17..000000000 --- a/core/database/src/main/java/com/into/websoso/core/database/datasource/library/paging/MapValuePagingSource.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.into.websoso.core.database.datasource.library.paging - -import androidx.paging.PagingSource -import androidx.paging.PagingState - -internal fun PagingSource.mapValue(mapper: suspend (From) -> To): PagingSource = - MapValuePagingSource(this, mapper) - -internal class MapValuePagingSource( - private val targetSource: PagingSource, - private val mapper: suspend (From) -> To, -) : PagingSource() { - init { - targetSource.registerInvalidatedCallback(::invalidate) - } - - override suspend fun load(params: LoadParams): LoadResult = - when (val result = targetSource.load(params)) { - is LoadResult.Page -> LoadResult.Page( - data = result.data.map { mapper(it) }, - prevKey = result.prevKey, - nextKey = result.nextKey, - itemsBefore = result.itemsBefore, - itemsAfter = result.itemsAfter, - ) - - is LoadResult.Error -> LoadResult.Error(result.throwable) - is LoadResult.Invalid -> LoadResult.Invalid() - } - - override val jumpingSupported: Boolean get() = targetSource.jumpingSupported - - @Suppress("UNCHECKED_CAST") - override fun getRefreshKey(state: PagingState): Key? = targetSource.getRefreshKey(state as PagingState) -} diff --git a/core/database/src/main/java/com/into/websoso/core/database/di/DatabaseModule.kt b/core/database/src/main/java/com/into/websoso/core/database/di/DatabaseModule.kt deleted file mode 100644 index 1c383df53..000000000 --- a/core/database/src/main/java/com/into/websoso/core/database/di/DatabaseModule.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.into.websoso.core.database.di - -import android.content.Context -import androidx.room.Room -import com.into.websoso.core.database.WebsosoDatabase -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object DatabaseModule { - @Provides - @Singleton - internal fun provideDatabase( - @ApplicationContext context: Context, - ): WebsosoDatabase = - Room - .databaseBuilder( - context, - WebsosoDatabase::class.java, - "websoso.db", - ).fallbackToDestructiveMigration() - .build() -} diff --git a/core/network/src/main/java/com/into/websoso/core/network/datasource/feed/FeedApi.kt b/core/network/src/main/java/com/into/websoso/core/network/datasource/feed/FeedApi.kt index ddc1b466a..7466c376c 100644 --- a/core/network/src/main/java/com/into/websoso/core/network/datasource/feed/FeedApi.kt +++ b/core/network/src/main/java/com/into/websoso/core/network/datasource/feed/FeedApi.kt @@ -1,14 +1,22 @@ package com.into.websoso.core.network.datasource.feed +import com.into.websoso.core.network.datasource.feed.model.request.CommentRequestDto +import com.into.websoso.core.network.datasource.feed.model.response.CommentsResponseDto +import com.into.websoso.core.network.datasource.feed.model.response.FeedDetailResponseDto import com.into.websoso.core.network.datasource.feed.model.response.FeedsResponseDto import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import okhttp3.MultipartBody import retrofit2.Retrofit +import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.Multipart import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Part import retrofit2.http.Path import retrofit2.http.Query import javax.inject.Singleton @@ -17,16 +25,46 @@ interface FeedApi { @GET("feeds") suspend fun getFeeds( @Query("category") category: String?, - @Query("feedsOption") feedsOption: String, + @Query("feedsOption") feedsOption: String, // [New] 옵션 추가됨 @Query("lastFeedId") lastFeedId: Long, @Query("size") size: Int, ): FeedsResponseDto + @GET("feeds/{feedId}") + suspend fun getFeed( + @Path("feedId") feedId: Long, + ): FeedDetailResponseDto + + @Multipart + @POST("feeds") + suspend fun postFeed( + @Part feedRequestDto: MultipartBody.Part, + @Part images: List?, + ) + + @Multipart + @PUT("feeds/{feedId}") + suspend fun putFeed( + @Path("feedId") feedId: Long, + @Part feedRequestDto: MultipartBody.Part, + @Part images: List?, + ) + @DELETE("feeds/{feedId}") suspend fun deleteFeed( @Path("feedId") feedId: Long, ) + @POST("feeds/{feedId}/likes") + suspend fun postLikes( + @Path("feedId") feedId: Long, + ) + + @DELETE("feeds/{feedId}/likes") + suspend fun deleteLikes( + @Path("feedId") feedId: Long, + ) + @POST("feeds/{feedId}/spoiler") suspend fun postSpoilerFeed( @Path("feedId") feedId: Long, @@ -37,14 +75,40 @@ interface FeedApi { @Path("feedId") feedId: Long, ) - @POST("feeds/{feedId}/likes") - suspend fun postLikes( + @GET("feeds/{feedId}/comments") + suspend fun getComments( + @Path("feedId") feedId: Long, + ): CommentsResponseDto + + @POST("feeds/{feedId}/comments") + suspend fun postComment( @Path("feedId") feedId: Long, + @Body commentRequestDto: CommentRequestDto, ) - @DELETE("feeds/{feedId}/likes") - suspend fun deleteLikes( + @PUT("feeds/{feedId}/comments/{commentId}") + suspend fun putComment( + @Path("feedId") feedId: Long, + @Path("commentId") commentId: Long, + @Body commentRequestDto: CommentRequestDto, + ) + + @DELETE("feeds/{feedId}/comments/{commentId}") + suspend fun deleteComment( + @Path("feedId") feedId: Long, + @Path("commentId") commentId: Long, + ) + + @POST("feeds/{feedId}/comments/{commentId}/spoiler") + suspend fun postSpoilerComment( + @Path("feedId") feedId: Long, + @Path("commentId") commentId: Long, + ) + + @POST("feeds/{feedId}/comments/{commentId}/impertinence") + suspend fun postImpertinenceComment( @Path("feedId") feedId: Long, + @Path("commentId") commentId: Long, ) } diff --git a/core/network/src/main/java/com/into/websoso/core/network/datasource/feed/model/response/FeedDetailResponseDto.kt b/core/network/src/main/java/com/into/websoso/core/network/datasource/feed/model/response/FeedDetailResponseDto.kt index 4c049be4b..a703b4a28 100644 --- a/core/network/src/main/java/com/into/websoso/core/network/datasource/feed/model/response/FeedDetailResponseDto.kt +++ b/core/network/src/main/java/com/into/websoso/core/network/datasource/feed/model/response/FeedDetailResponseDto.kt @@ -43,10 +43,14 @@ data class FeedDetailResponseDto( val isPublic: Boolean, @SerialName("images") val images: List, - @SerialName("genreName") - val genreName: String?, - @SerialName("userNovelRating") - val userNovelRating: Float?, + @SerialName("novelThumbnailImage") + val novelThumbnailImage: String?, + @SerialName("novelGenre") + val novelGenre: String?, + @SerialName("novelAuthor") + val novelAuthor: String?, @SerialName("feedWriterNovelRating") val feedWriterNovelRating: Float?, + @SerialName("novelDescription") + val novelDescription: String?, ) diff --git a/core/resource/src/main/res/drawable/ic_like.xml b/core/resource/src/main/res/drawable/ic_like.xml new file mode 100644 index 000000000..a0fb8b28a --- /dev/null +++ b/core/resource/src/main/res/drawable/ic_like.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/resource/src/main/res/drawable/ic_navigate_left.xml b/core/resource/src/main/res/drawable/ic_navigate_left.xml new file mode 100644 index 000000000..c642d6c31 --- /dev/null +++ b/core/resource/src/main/res/drawable/ic_navigate_left.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/resource/src/main/res/drawable/ic_three_dots.xml b/core/resource/src/main/res/drawable/ic_three_dots.xml new file mode 100644 index 000000000..a2bd88b4a --- /dev/null +++ b/core/resource/src/main/res/drawable/ic_three_dots.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/resource/src/main/res/drawable/ic_thumb_up_on.xml b/core/resource/src/main/res/drawable/ic_thumb_up_on.xml index 39b5bf15a..427949583 100644 --- a/core/resource/src/main/res/drawable/ic_thumb_up_on.xml +++ b/core/resource/src/main/res/drawable/ic_thumb_up_on.xml @@ -1,8 +1,8 @@ + android:width="22dp" + android:height="22dp" + android:viewportWidth="22" + android:viewportHeight="22"> + FeedDetailEntity.NovelEntity( + id = id, + title = title.orEmpty(), + rating = novelRating, + ratingCount = novelRatingCount ?: 0, + genre = novelGenre.orEmpty(), + feedWriterNovelRating = feedWriterNovelRating, + thumbnail = this.images.firstOrNull().orEmpty(), + author = novelAuthor.orEmpty(), + description = novelDescription.orEmpty(), + ) + }, + ) +} + fun FeedResponseDto.toData(): FeedEntity = FeedEntity( user = FeedEntity.UserEntity( @@ -75,37 +113,6 @@ fun CommentResponseDto.toData(): CommentEntity = isSpoiler = isSpoiler, ) -fun FeedDetailResponseDto.toData(): FeedEntity = - FeedEntity( - user = FeedEntity.UserEntity( - id = userId, - nickname = nickname, - avatarImage = avatarImage, - ), - createdDate = createdDate, - id = feedId, - content = feedContent, - relevantCategories = relevantCategories, - likeCount = likeCount, - isLiked = isLiked, - commentCount = commentCount, - isModified = isModified, - isSpoiler = isSpoiler, - isMyFeed = isMyFeed, - isPublic = isPublic, - images = images, - imageCount = images.size, - novel = FeedEntity.NovelEntity( - id = novelId, - title = title, - rating = novelRating, - ratingCount = novelRatingCount, - ), - genreName = genreName, - userNovelRating = userNovelRating, - feedWriterNovelRating = feedWriterNovelRating, - ) - fun PopularFeedsResponseDto.toData(): PopularFeedsEntity = PopularFeedsEntity( popularFeeds = popularFeeds.map { feed -> diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/model/FeedDetailEntity.kt b/data/feed/src/main/java/com/into/websoso/data/feed/model/FeedDetailEntity.kt new file mode 100644 index 000000000..d7afd726c --- /dev/null +++ b/data/feed/src/main/java/com/into/websoso/data/feed/model/FeedDetailEntity.kt @@ -0,0 +1,37 @@ +package com.into.websoso.data.feed.model + +data class FeedDetailEntity( + val user: UserEntity, + val createdDate: String, + val id: Long, + val content: String, + val relevantCategories: List, + val likeCount: Int, + val isLiked: Boolean, + val commentCount: Int, + val isModified: Boolean, + val isSpoiler: Boolean, + val isMyFeed: Boolean, + val isPublic: Boolean, + val novel: NovelEntity?, + val images: List, + val imageCount: Int, +) { + data class UserEntity( + val id: Long, + val nickname: String, + val avatarImage: String, + ) + + data class NovelEntity( + val id: Long, + val title: String, + val rating: Float?, + val ratingCount: Int, + val thumbnail: String, + val genre: String, + val author: String, + val description: String, + val feedWriterNovelRating: Float?, + ) +} diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/FeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/FeedRepository.kt index b7575f57a..dfc174616 100644 --- a/data/feed/src/main/java/com/into/websoso/data/feed/repository/FeedRepository.kt +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/FeedRepository.kt @@ -5,107 +5,90 @@ import com.into.websoso.core.network.datasource.feed.FeedApi import com.into.websoso.data.feed.mapper.toData import com.into.websoso.data.feed.model.FeedEntity import com.into.websoso.data.feed.model.FeedsEntity -import com.into.websoso.data.library.datasource.LibraryLocalDataSource -import com.into.websoso.data.library.model.NovelEntity import javax.inject.Inject import javax.inject.Singleton -// TODO: 이 부분 확인 후 수정 후 주석 모두 제거 +@Deprecated("피드 QA 완료 후 제거 예정") @Singleton class FeedRepository - @Inject - constructor( - private val feedApi: FeedApi, - private val libraryLocalDataSource: LibraryLocalDataSource, - ) { - // 내부 상태 캡슐화 (외부에서 직접 수정 불가) - private val cachedFeeds = mutableListOf() - private val cachedRecommendedFeeds = mutableListOf() - - /** - * 커서 기반 피드 조회 - * @param lastFeedId: 현재 리스트의 마지막 ID (0일 경우 최신순 새로고침) - * @param size: 가져올 개수 - * @param feedsOption: "RECOMMENDED" 또는 기타 옵션 - */ - suspend fun fetchFeeds( - lastFeedId: Long, - size: Int, - feedsOption: String, - ): FeedsEntity { - // 1. 서버로부터 데이터 fetch - val result = feedApi - .getFeeds( - feedsOption = feedsOption, - category = null, - lastFeedId = lastFeedId, - size = size, - ).toData() - - // 2. 타겟 캐시 결정 - val isRecommended = feedsOption == "RECOMMENDED" - val targetCache = if (isRecommended) cachedRecommendedFeeds else cachedFeeds +@Inject +constructor( + private val feedApi: FeedApi, +) { + // 내부 상태 캡슐화 (외부에서 직접 수정 불가) + private val cachedFeeds = mutableListOf() + private val cachedRecommendedFeeds = mutableListOf() - // 3. 커서(lastFeedId) 값에 따른 전략 수행 - // lastFeedId가 0이면 사용자가 새로고침을 했거나 처음 진입한 상황임 - if (lastFeedId == 0L) { - targetCache.clear() - } + /** + * 커서 기반 피드 조회 + * @param lastFeedId: 현재 리스트의 마지막 ID (0일 경우 최신순 새로고침) + * @param size: 가져올 개수 + * @param feedsOption: "RECOMMENDED" 또는 기타 옵션 + */ + suspend fun fetchFeeds( + lastFeedId: Long, + size: Int, + feedsOption: String, + ): FeedsEntity { + // 1. 서버로부터 데이터 fetch + val result = feedApi + .getFeeds( + feedsOption = feedsOption, + category = null, + lastFeedId = lastFeedId, + size = size, + ).toData() - // 4. 데이터 중복 방지 및 추가 (Server-side Cursor의 신뢰성 확보) - // 혹시라도 서버에서 중복 데이터가 내려올 경우를 대비해 ID 기준으로 필터링 후 추가 - val existingIds = targetCache.map { it.id }.toSet() - val newUniqueFeeds = result.feeds.filterNot { it.id in existingIds } - targetCache.addAll(newUniqueFeeds) + // 2. 타겟 캐시 결정 + val isRecommended = feedsOption == "RECOMMENDED" + val targetCache = if (isRecommended) cachedRecommendedFeeds else cachedFeeds - // 5. 현재까지 누적된 전체 리스트를 담은 Entity 반환 - return result.copy(feeds = targetCache.toList()) + // 3. 커서(lastFeedId) 값에 따른 전략 수행 + // lastFeedId가 0이면 사용자가 새로고침을 했거나 처음 진입한 상황임 + if (lastFeedId == 0L) { + targetCache.clear() } - /** - * 특정 피드 삭제 및 로컬 동기화 - */ - suspend fun saveRemovedFeed( - feedId: Long, - novelId: Long?, - content: String, - ) { - runCatching { - feedApi.deleteFeed(feedId) - }.onSuccess { - // 모든 캐시에서 해당 피드 제거 (안전하게 두 곳 모두 확인 가능) - cachedFeeds.removeIf { it.id == feedId } - cachedRecommendedFeeds.removeIf { it.id == feedId } + // 4. 데이터 중복 방지 및 추가 (Server-side Cursor의 신뢰성 확보) + // 혹시라도 서버에서 중복 데이터가 내려올 경우를 대비해 ID 기준으로 필터링 후 추가 + val existingIds = targetCache.map { it.id }.toSet() + val newUniqueFeeds = result.feeds.filterNot { it.id in existingIds } + targetCache.addAll(newUniqueFeeds) - // 로컬 DB(Library) 동기화 로직 - val novel: NovelEntity? = - novelId?.let { libraryLocalDataSource.selectNovelByNovelId(it) } - if (novel != null) { - val updatedNovel = novel.copy( - myFeeds = novel.myFeeds.filterNot { it == content }, - ) - libraryLocalDataSource.insertNovel(updatedNovel) - } - }.onFailure { - Log.d("FeedRepository", "saveRemovedFeed 함수 failed : ${it.message}") - } - } + // 5. 현재까지 누적된 전체 리스트를 담은 Entity 반환 + return result.copy(feeds = targetCache.toList()) + } - suspend fun saveSpoilerFeed(feedId: Long) { - feedApi.postSpoilerFeed(feedId) + /** + * 특정 피드 삭제 및 로컬 동기화 + */ + suspend fun saveRemovedFeed(feedId: Long) { + runCatching { + feedApi.deleteFeed(feedId) + }.onSuccess { + // 모든 캐시에서 해당 피드 제거 (안전하게 두 곳 모두 확인 가능) + cachedFeeds.removeIf { it.id == feedId } + cachedRecommendedFeeds.removeIf { it.id == feedId } + }.onFailure { + Log.d("FeedRepository", "saveRemovedFeed 함수 failed : ${it.message}") } + } - suspend fun saveImpertinenceFeed(feedId: Long) { - feedApi.postImpertinenceFeed(feedId) - } + suspend fun saveSpoilerFeed(feedId: Long) { + feedApi.postSpoilerFeed(feedId) + } - suspend fun saveLike( - isLikedOfLikedFeed: Boolean, - selectedFeedId: Long, - ) { - when (isLikedOfLikedFeed) { - true -> feedApi.deleteLikes(selectedFeedId) - false -> feedApi.postLikes(selectedFeedId) - } + suspend fun saveImpertinenceFeed(feedId: Long) { + feedApi.postImpertinenceFeed(feedId) + } + + suspend fun saveLike( + isLikedOfLikedFeed: Boolean, + selectedFeedId: Long, + ) { + when (isLikedOfLikedFeed) { + true -> feedApi.deleteLikes(selectedFeedId) + false -> feedApi.postLikes(selectedFeedId) } } +} diff --git a/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt new file mode 100644 index 000000000..4c95f5d32 --- /dev/null +++ b/data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt @@ -0,0 +1,293 @@ +package com.into.websoso.data.feed.repository + +import android.util.Log +import com.into.websoso.core.common.dispatchers.Dispatcher +import com.into.websoso.core.common.dispatchers.WebsosoDispatchers +import com.into.websoso.core.network.datasource.feed.FeedApi +import com.into.websoso.core.network.datasource.feed.model.request.CommentRequestDto +import com.into.websoso.data.feed.mapper.toData +import com.into.websoso.data.feed.model.CommentsEntity +import com.into.websoso.data.feed.model.FeedDetailEntity +import com.into.websoso.data.feed.model.FeedEntity +import com.into.websoso.data.feed.model.FeedsEntity +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UpdatedFeedRepository @Inject constructor( + private val feedApi: FeedApi, + @Dispatcher(WebsosoDispatchers.IO) private val dispatcher: CoroutineDispatcher, +) { + private val scope = CoroutineScope(SupervisorJob() + dispatcher) + + private val _sosoAllFeeds = MutableStateFlow>(emptyList()) + val sosoAllFeeds = _sosoAllFeeds.asStateFlow() + + private val _sosoRecommendedFeeds = MutableStateFlow>(emptyList()) + val sosoRecommendedFeeds = _sosoRecommendedFeeds.asStateFlow() + + private val _myFeeds = MutableStateFlow>(emptyList()) + val myFeeds = _myFeeds.asStateFlow() + + private val dirtyFeedStates = ConcurrentHashMap() + private val originalFeedStates = ConcurrentHashMap() + + // ============================================================================================ + // Feed List & Caching Logic + // ============================================================================================ + + /** + * 서버에서 피드 리스트를 조회하고, 로컬의 미동기화된 좋아요 상태(Dirty)를 병합하여 캐시(Flow)를 갱신합니다. + */ + suspend fun fetchFeeds( + lastFeedId: Long, + size: Int, + feedsOption: String, + ): FeedsEntity { + val result = feedApi.getFeeds( + feedsOption = feedsOption, + category = null, + lastFeedId = lastFeedId, + size = size, + ).toData() + + val mergedFeeds = result.feeds.map { feed -> applyDirtyState(feed) } + + val isRecommended = feedsOption == "RECOMMENDED" + val targetFlow = if (isRecommended) _sosoRecommendedFeeds else _sosoAllFeeds + + targetFlow.update { currentList -> + if (lastFeedId == 0L) { + mergedFeeds + } else { + val newFeeds = mergedFeeds.filterNot { new -> currentList.any { it.id == new.id } } + currentList + newFeeds + } + } + + return result.copy(feeds = targetFlow.value) + } + + /** + * 외부(UseCase 등)에서 가져온 내 피드 데이터를 캐시에 주입합니다. + */ + fun updateMyFeedsCache(feeds: List, isRefreshed: Boolean) { + val mergedFeeds = feeds.map { feed -> applyDirtyState(feed) } + + _myFeeds.update { current -> + if (isRefreshed) mergedFeeds else (current + mergedFeeds).distinctBy { it.id } + } + } + + /** + * 서버 데이터(Stale)보다 로컬의 변경사항(Fresh)을 우선 적용하여 반환합니다. + */ + private fun applyDirtyState(feed: FeedEntity): FeedEntity { + val localIsLiked = dirtyFeedStates[feed.id] ?: return feed + + if (feed.isLiked != localIsLiked) { + val adjustedCount = if (localIsLiked) feed.likeCount + 1 else feed.likeCount - 1 + return feed.copy( + isLiked = localIsLiked, + likeCount = adjustedCount.coerceAtLeast(0), + ) + } + return feed + } + + // ============================================================================================ + // Interaction (Like, Sync) + // ============================================================================================ + + /** + * API 호출 없이 로컬 캐시의 좋아요 상태를 즉시 토글하고, 변경 내역을 Dirty Map에 기록합니다. + */ + fun toggleLikeLocal(feedId: Long) { + updateFeedInFlow(_sosoAllFeeds, feedId) + updateFeedInFlow(_sosoRecommendedFeeds, feedId) + updateFeedInFlow(_myFeeds, feedId) + } + + private fun updateFeedInFlow(flow: MutableStateFlow>, feedId: Long) { + flow.update { list -> + val index = list.indexOfFirst { it.id == feedId } + if (index == -1) return@update list + + val target = list[index] + val newLiked = !target.isLiked + val newCount = if (newLiked) target.likeCount + 1 else target.likeCount - 1 + + trackDirtyState(feedId, target.isLiked, newLiked) + + val newList = list.toMutableList() + newList[index] = target.copy(isLiked = newLiked, likeCount = newCount) + newList + } + } + + /** + * 변경된 좋아요 상태를 추적합니다. + */ + private fun trackDirtyState(feedId: Long, original: Boolean, new: Boolean) { + originalFeedStates.putIfAbsent(feedId, original) + if (originalFeedStates[feedId] == new) { + dirtyFeedStates.remove(feedId) + } else { + dirtyFeedStates[feedId] = new + } + } + + /** + * Dirty Map에 기록된 변경된 좋아요 상태들을 백그라운드에서 서버와 동기화합니다. + */ + fun syncDirtyFeeds() { + if (dirtyFeedStates.isEmpty()) return + + val syncMap = dirtyFeedStates.toMap() + dirtyFeedStates.clear() + originalFeedStates.clear() + + scope.launch { + syncMap.forEach { (id, isLiked) -> + runCatching { + if (isLiked) feedApi.postLikes(id) else feedApi.deleteLikes(id) + }.onFailure { + Log.e("UpdatedFeedRepository", "Failed to sync feed $id", it) + } + } + } + } + + // ============================================================================================ + // Feed Actions (Remove, Report) + // ============================================================================================ + + /** + * 피드를 삭제하고, 성공 시 로컬 캐시(Flow)에서도 해당 피드를 제거합니다. + */ + suspend fun saveRemovedFeed(feedId: Long) { + runCatching { + feedApi.deleteFeed(feedId) + }.onSuccess { + removeFromFlow(_sosoAllFeeds, feedId) + removeFromFlow(_sosoRecommendedFeeds, feedId) + removeFromFlow(_myFeeds, feedId) + } + } + + /** + * 피드를 스포일러로 신고하고, 성공 시 로컬 캐시(Flow)에 스포일러 상태를 반영합니다. + */ + suspend fun saveSpoilerFeed(feedId: Long) { + runCatching { + feedApi.postSpoilerFeed(feedId) + }.onSuccess { + markAsSpoilerInFlow(_sosoAllFeeds, feedId) + markAsSpoilerInFlow(_sosoRecommendedFeeds, feedId) + markAsSpoilerInFlow(_myFeeds, feedId) + } + } + + /** + * 피드를 부적절한 게시물로 신고하고, 성공 시 로컬 캐시(Flow)에서 제거합니다. + */ + suspend fun saveImpertinenceFeed(feedId: Long) { + runCatching { + feedApi.postImpertinenceFeed(feedId) + }.onSuccess { + removeFromFlow(_sosoAllFeeds, feedId) + removeFromFlow(_sosoRecommendedFeeds, feedId) + removeFromFlow(_myFeeds, feedId) + } + } + + private fun removeFromFlow(flow: MutableStateFlow>, feedId: Long) { + flow.update { list -> list.filterNot { it.id == feedId } } + } + + private fun markAsSpoilerInFlow(flow: MutableStateFlow>, feedId: Long) { + flow.update { list -> + list.map { if (it.id == feedId) it.copy(isSpoiler = true) else it } + } + } + + // ============================================================================================ + // Feed Detail & Comments + // ============================================================================================ + + /** + * 피드 상세 정보를 조회합니다. + */ + suspend fun fetchFeed(feedId: Long): FeedDetailEntity { + val rawDetail = feedApi.getFeed(feedId).toData() + return applyDirtyStateToDetail(rawDetail) + } + + /** + * FeedDetailEntity용 로컬 상태 병합 로직입니다. + */ + private fun applyDirtyStateToDetail(feed: FeedDetailEntity): FeedDetailEntity { + val localIsLiked = dirtyFeedStates[feed.id] ?: return feed + + if (feed.isLiked != localIsLiked) { + val adjustedCount = if (localIsLiked) feed.likeCount + 1 else feed.likeCount - 1 + return feed.copy( + isLiked = localIsLiked, + likeCount = adjustedCount.coerceAtLeast(0), + ) + } + return feed + } + + /** + * 특정 피드의 댓글 목록을 조회합니다. + */ + suspend fun fetchComments(feedId: Long): CommentsEntity { + return feedApi.getComments(feedId).toData() + } + + /** + * 새 댓글을 등록합니다. + */ + suspend fun saveComment(feedId: Long, comment: String) { + val commentRequestDto = CommentRequestDto(commentContent = comment) + feedApi.postComment(feedId, commentRequestDto) + } + + /** + * 기존 댓글을 수정합니다. + */ + suspend fun saveModifiedComment(feedId: Long, commentId: Long, comment: String) { + val commentRequestDto = CommentRequestDto(commentContent = comment) + feedApi.putComment(feedId, commentId, commentRequestDto) + } + + /** + * 댓글을 삭제합니다. + */ + suspend fun deleteComment(feedId: Long, commentId: Long) { + feedApi.deleteComment(feedId, commentId) + } + + /** + * 댓글을 스포일러로 신고합니다. + */ + suspend fun saveSpoilerComment(feedId: Long, commentId: Long) { + feedApi.postSpoilerComment(feedId, commentId) + } + + /** + * 댓글을 부적절한 내용으로 신고합니다. + */ + suspend fun saveImpertinenceComment(feedId: Long, commentId: Long) { + feedApi.postImpertinenceComment(feedId, commentId) + } +} diff --git a/data/library/src/main/java/com/into/websoso/data/library/LibraryRepository.kt b/data/library/src/main/java/com/into/websoso/data/library/LibraryRepository.kt index d79391b5b..876407ea3 100644 --- a/data/library/src/main/java/com/into/websoso/data/library/LibraryRepository.kt +++ b/data/library/src/main/java/com/into/websoso/data/library/LibraryRepository.kt @@ -5,7 +5,8 @@ import com.into.websoso.data.library.model.NovelEntity import kotlinx.coroutines.flow.Flow interface LibraryRepository { - val libraryFlow: Flow> + val novelTotalCount: Flow + fun getLibraryFlow(): Flow> companion object { const val PAGE_SIZE = 20 diff --git a/data/library/src/main/java/com/into/websoso/data/library/datasource/LibraryLocalDataSource.kt b/data/library/src/main/java/com/into/websoso/data/library/datasource/LibraryLocalDataSource.kt deleted file mode 100644 index 587a3147f..000000000 --- a/data/library/src/main/java/com/into/websoso/data/library/datasource/LibraryLocalDataSource.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.into.websoso.data.library.datasource - -import androidx.paging.PagingSource -import com.into.websoso.data.library.model.NovelEntity - -interface LibraryLocalDataSource { - suspend fun insertNovels(novels: List) - - suspend fun insertNovel(novel: NovelEntity) - - fun selectAllNovels(): PagingSource - - suspend fun selectLastNovel(): NovelEntity? - - suspend fun selectNovelByUserNovelId(userNovelId: Long): NovelEntity? - - suspend fun selectNovelByNovelId(novelId: Long): NovelEntity? - - suspend fun selectAllNovelsCount(): Int - - suspend fun deleteAllNovels() - - suspend fun deleteNovel(novelId: Long) -} diff --git a/data/library/src/main/java/com/into/websoso/data/library/paging/LibraryPagingSource.kt b/data/library/src/main/java/com/into/websoso/data/library/paging/LibraryPagingSource.kt index 0db494a3c..14933eec9 100644 --- a/data/library/src/main/java/com/into/websoso/data/library/paging/LibraryPagingSource.kt +++ b/data/library/src/main/java/com/into/websoso/data/library/paging/LibraryPagingSource.kt @@ -1,8 +1,6 @@ package com.into.websoso.data.library.paging import androidx.paging.PagingSource -import androidx.paging.PagingSource.LoadResult.Error -import androidx.paging.PagingSource.LoadResult.Page import androidx.paging.PagingState import com.into.websoso.data.library.model.NovelEntity import com.into.websoso.data.library.model.UserNovelsEntity @@ -10,30 +8,35 @@ import com.into.websoso.data.library.model.UserNovelsEntity class LibraryPagingSource( private val getNovels: suspend (lastUserNovelId: Long) -> Result, ) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { - val lastUserNovelId = params.key ?: DEFAULT_LAST_USER_NOVEL_ID + val currentKey = params.key ?: DEFAULT_LAST_USER_NOVEL_ID - return getNovels(lastUserNovelId).fold( + return getNovels(currentKey).fold( onSuccess = { result -> - val nextKey = if (result.isLoadable && result.userNovels.isNotEmpty()) { - result.userNovels.last().userNovelId + val novels = result.userNovels + + val nextKey = if (result.isLoadable && novels.isNotEmpty()) { + novels.last().userNovelId } else { null } - Page( - data = result.userNovels, + LoadResult.Page( + data = novels, prevKey = null, nextKey = nextKey, ) }, onFailure = { throwable -> - Error(throwable) + LoadResult.Error(throwable) }, ) } - override fun getRefreshKey(state: PagingState): Long? = DEFAULT_LAST_USER_NOVEL_ID + override fun getRefreshKey(state: PagingState): Long? { + return null + } companion object { private const val DEFAULT_LAST_USER_NOVEL_ID = 0L diff --git a/data/library/src/main/java/com/into/websoso/data/library/paging/LibraryRemoteMediator.kt b/data/library/src/main/java/com/into/websoso/data/library/paging/LibraryRemoteMediator.kt deleted file mode 100644 index f7365ab8b..000000000 --- a/data/library/src/main/java/com/into/websoso/data/library/paging/LibraryRemoteMediator.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.into.websoso.data.library.paging - -import androidx.paging.ExperimentalPagingApi -import androidx.paging.LoadType -import androidx.paging.LoadType.APPEND -import androidx.paging.LoadType.PREPEND -import androidx.paging.LoadType.REFRESH -import androidx.paging.PagingState -import androidx.paging.RemoteMediator -import androidx.paging.RemoteMediator.MediatorResult.Error -import androidx.paging.RemoteMediator.MediatorResult.Success -import com.into.websoso.data.library.model.NovelEntity -import com.into.websoso.data.library.model.UserNovelsEntity - -@OptIn(ExperimentalPagingApi::class) -class LibraryRemoteMediator( - private val getNovels: suspend (lastUserNovelId: Long) -> Result, - private val getLastNovel: suspend () -> NovelEntity?, - private val deleteAllNovels: suspend () -> Unit, - private val insertNovels: suspend (List) -> Unit, -) : RemoteMediator() { - override suspend fun load( - loadType: LoadType, - state: PagingState, - ): MediatorResult { - val lastUserNovelId = when (loadType) { - REFRESH -> { - deleteAllNovels() - DEFAULT_LAST_USER_NOVEL_ID - } - - APPEND -> { - val lastNovel = getLastNovel() - lastNovel?.userNovelId - } - - PREPEND -> null - } ?: return Success(true) - - return getNovels(lastUserNovelId).fold( - onSuccess = { result -> - insertNovels(result.userNovels) - Success(endOfPaginationReached = !result.isLoadable) - }, - onFailure = { throwable -> - Error(throwable) - }, - ) - } - - companion object { - private const val DEFAULT_LAST_USER_NOVEL_ID = 0L - } -} diff --git a/data/library/src/main/java/com/into/websoso/data/library/repository/MyLibraryRepository.kt b/data/library/src/main/java/com/into/websoso/data/library/repository/MyLibraryRepository.kt index 60d07830d..5a6d3e7ef 100644 --- a/data/library/src/main/java/com/into/websoso/data/library/repository/MyLibraryRepository.kt +++ b/data/library/src/main/java/com/into/websoso/data/library/repository/MyLibraryRepository.kt @@ -1,6 +1,5 @@ package com.into.websoso.data.library.repository -import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData @@ -11,67 +10,73 @@ import com.into.websoso.data.filter.model.DEFAULT_NOVEL_RATING import com.into.websoso.data.filter.model.LibraryFilter import com.into.websoso.data.library.LibraryRepository import com.into.websoso.data.library.LibraryRepository.Companion.PAGE_SIZE -import com.into.websoso.data.library.datasource.LibraryLocalDataSource import com.into.websoso.data.library.datasource.LibraryRemoteDataSource import com.into.websoso.data.library.model.NovelEntity -import com.into.websoso.data.library.paging.LibraryRemoteMediator +import com.into.websoso.data.library.paging.LibraryPagingSource import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.update import javax.inject.Inject class MyLibraryRepository - @Inject - constructor( - filterRepository: FilterRepository, - private val accountRepository: AccountRepository, - private val libraryRemoteDataSource: LibraryRemoteDataSource, - private val libraryLocalDataSource: LibraryLocalDataSource, - ) : LibraryRepository { - @OptIn(ExperimentalPagingApi::class, ExperimentalCoroutinesApi::class) - override val libraryFlow: Flow> = - filterRepository.filterFlow - .flatMapLatest { filter -> - Pager( - config = PagingConfig( - pageSize = PAGE_SIZE, - enablePlaceholders = false, - ), - remoteMediator = LibraryRemoteMediator( +@Inject +constructor( + private val filterRepository: FilterRepository, + private val accountRepository: AccountRepository, + private val libraryRemoteDataSource: LibraryRemoteDataSource, +) : LibraryRepository { + private var _novelTotalCount: MutableStateFlow = MutableStateFlow(0) + override val novelTotalCount: Flow = _novelTotalCount.asStateFlow() + + /** + * Room 캐싱 없이 서버와 직접 통신하며, 필터 변경 시 스트림을 재생성합니다. + */ + @OptIn(ExperimentalCoroutinesApi::class) + override fun getLibraryFlow(): Flow> = + filterRepository.filterFlow + .flatMapLatest { currentFilter -> + Pager( + config = PagingConfig( + pageSize = PAGE_SIZE, + enablePlaceholders = false, + ), + pagingSourceFactory = { + LibraryPagingSource( getNovels = { lastUserNovelId -> - getUserNovels(lastUserNovelId, filter) + getUserNovels(lastUserNovelId, currentFilter).also { result -> + _novelTotalCount.update { + result.getOrNull()?.userNovelCount ?: 0 + } + } }, - getLastNovel = libraryLocalDataSource::selectLastNovel, - deleteAllNovels = libraryLocalDataSource::deleteAllNovels, - insertNovels = libraryLocalDataSource::insertNovels, - ), - pagingSourceFactory = libraryLocalDataSource::selectAllNovels, - ).flow - } + ) + }, + ).flow + } - suspend fun deleteAllNovels() { - libraryLocalDataSource.deleteAllNovels() - } - private suspend fun getUserNovels( - lastUserNovelId: Long, - libraryFilter: LibraryFilter, - ) = runCatching { - libraryRemoteDataSource.getUserNovels( - userId = accountRepository.userId, - lastUserNovelId = lastUserNovelId, - size = PAGE_SIZE, - sortCriteria = libraryFilter.sortCriteria, - isInterest = if (!libraryFilter.isInterested) null else true, - readStatuses = libraryFilter.readStatuses.ifEmpty { null }, - attractivePoints = libraryFilter.attractivePoints.ifEmpty { null }, - novelRating = if (libraryFilter.novelRating.isCloseTo(DEFAULT_NOVEL_RATING)) { - null - } else { - libraryFilter.novelRating - }, - query = null, - updatedSince = null, - ) - } + private suspend fun getUserNovels( + lastUserNovelId: Long, + libraryFilter: LibraryFilter, + ) = runCatching { + libraryRemoteDataSource.getUserNovels( + userId = accountRepository.userId, + lastUserNovelId = lastUserNovelId, + size = PAGE_SIZE, + sortCriteria = libraryFilter.sortCriteria, + isInterest = if (!libraryFilter.isInterested) null else true, + readStatuses = libraryFilter.readStatuses.ifEmpty { null }, + attractivePoints = libraryFilter.attractivePoints.ifEmpty { null }, + novelRating = if (libraryFilter.novelRating.isCloseTo(DEFAULT_NOVEL_RATING)) { + null + } else { + libraryFilter.novelRating + }, + query = null, + updatedSince = null, + ) } +} diff --git a/data/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.kt b/data/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.kt index a3e049c52..d247c17fa 100644 --- a/data/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.kt +++ b/data/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.kt @@ -17,55 +17,71 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.update internal class UserLibraryRepository - @AssistedInject - constructor( - filterRepository: FilterRepository, - @Assisted private val userId: Long, - private val libraryRemoteDataSource: LibraryRemoteDataSource, - ) : LibraryRepository { - @AssistedFactory - interface Factory { - fun create(userId: Long): UserLibraryRepository - } +@AssistedInject +constructor( + private val filterRepository: FilterRepository, + @Assisted private val userId: Long, + private val libraryRemoteDataSource: LibraryRemoteDataSource, +) : LibraryRepository { + private var _novelTotalCount: MutableStateFlow = MutableStateFlow(0) + override val novelTotalCount: Flow = _novelTotalCount.asStateFlow() - @OptIn(ExperimentalCoroutinesApi::class) - override val libraryFlow: Flow> = - filterRepository.filterFlow - .flatMapLatest { filter -> - Pager( - config = PagingConfig(pageSize = PAGE_SIZE), - pagingSourceFactory = { - LibraryPagingSource( - getNovels = { lastUserNovelId -> - getUserNovels(lastUserNovelId, filter) - }, - ) - }, - ).flow - } + @AssistedFactory + interface Factory { + fun create(userId: Long): UserLibraryRepository + } + + /** + * Room 캐싱 없이 서버와 직접 통신하며, 필터 변경 시 스트림을 재생성합니다. + */ + @OptIn(ExperimentalCoroutinesApi::class) + override fun getLibraryFlow(): Flow> = + filterRepository.filterFlow + .flatMapLatest { currentFilter -> + Pager( + config = PagingConfig( + pageSize = PAGE_SIZE, + enablePlaceholders = false, + ), + pagingSourceFactory = { + LibraryPagingSource( + getNovels = { lastUserNovelId -> + getUserNovels(lastUserNovelId, currentFilter).also { result -> + _novelTotalCount.update { + result.getOrNull()?.userNovelCount ?: 0 + } + } + }, + ) + }, + ).flow + } - private suspend fun getUserNovels( - lastUserNovelId: Long, - libraryFilter: LibraryFilter, - ) = runCatching { - libraryRemoteDataSource.getUserNovels( - userId = userId, - lastUserNovelId = lastUserNovelId, - size = PAGE_SIZE, - sortCriteria = libraryFilter.sortCriteria, - isInterest = if (!libraryFilter.isInterested) null else true, - readStatuses = libraryFilter.readStatuses.ifEmpty { null }, - attractivePoints = libraryFilter.attractivePoints.ifEmpty { null }, - novelRating = if (libraryFilter.novelRating.isCloseTo(DEFAULT_NOVEL_RATING)) { - null - } else { - libraryFilter.novelRating - }, - query = null, - updatedSince = null, - ) - } + private suspend fun getUserNovels( + lastUserNovelId: Long, + libraryFilter: LibraryFilter, + ) = runCatching { + libraryRemoteDataSource.getUserNovels( + userId = userId, + lastUserNovelId = lastUserNovelId, + size = PAGE_SIZE, + sortCriteria = libraryFilter.sortCriteria, + isInterest = if (!libraryFilter.isInterested) null else true, + readStatuses = libraryFilter.readStatuses.ifEmpty { null }, + attractivePoints = libraryFilter.attractivePoints.ifEmpty { null }, + novelRating = if (libraryFilter.novelRating.isCloseTo(DEFAULT_NOVEL_RATING)) { + null + } else { + libraryFilter.novelRating + }, + query = null, + updatedSince = null, + ) } +} diff --git a/domain/feed/src/main/java/com/into/websoso/feed/UpdatedGetFeedsUseCase.kt b/domain/feed/src/main/java/com/into/websoso/feed/UpdatedGetFeedsUseCase.kt new file mode 100644 index 000000000..c11226335 --- /dev/null +++ b/domain/feed/src/main/java/com/into/websoso/feed/UpdatedGetFeedsUseCase.kt @@ -0,0 +1,46 @@ +package com.into.websoso.feed + +import com.into.websoso.data.feed.repository.UpdatedFeedRepository +import com.into.websoso.feed.mapper.toDomain +import com.into.websoso.feed.model.Feed +import com.into.websoso.feed.model.Feeds +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class UpdatedGetFeedsUseCase @Inject constructor( + private val feedRepository: UpdatedFeedRepository, +) { + val sosoAllFlow: Flow> = feedRepository.sosoAllFeeds + .map { list -> list.map { it.toDomain() } } + + val sosoRecommendedFlow: Flow> = feedRepository.sosoRecommendedFeeds + .map { list -> list.map { it.toDomain() } } + + /** + * [Modified] 데이터 로드 트리거 + * - 서버에서 데이터를 가져와 Repository 내부의 HotFlow를 업데이트합니다. + * - 반환값은 UI의 페이징 상태(isLoadable, lastId) 관리를 위한 메타데이터입니다. + */ + suspend operator fun invoke( + feedsOption: String = DEFAULT_OPTION, + lastFeedId: Long = INITIAL_ID, + ): Feeds { + val isFeedRefreshed: Boolean = lastFeedId == INITIAL_ID + + val feedsEntity = feedRepository.fetchFeeds( + lastFeedId = lastFeedId, + size = if (isFeedRefreshed) INITIAL_REQUEST_SIZE else ADDITIONAL_REQUEST_SIZE, + feedsOption = feedsOption, + ) + + return feedsEntity.toDomain() + } + + companion object { + private const val DEFAULT_OPTION = "ALL" + private const val INITIAL_ID: Long = 0 + private const val INITIAL_REQUEST_SIZE = 40 + private const val ADDITIONAL_REQUEST_SIZE = 20 + } +} diff --git a/domain/feed/src/main/java/com/into/websoso/feed/UpdatedGetMyFeedsUseCase.kt b/domain/feed/src/main/java/com/into/websoso/feed/UpdatedGetMyFeedsUseCase.kt new file mode 100644 index 000000000..09745331f --- /dev/null +++ b/domain/feed/src/main/java/com/into/websoso/feed/UpdatedGetMyFeedsUseCase.kt @@ -0,0 +1,102 @@ +package com.into.websoso.feed + +import com.into.websoso.data.feed.model.FeedEntity +import com.into.websoso.data.feed.repository.UpdatedFeedRepository +import com.into.websoso.feed.mapper.toDomain +import com.into.websoso.feed.model.Feed +import com.into.websoso.feed.model.Feeds +import com.into.websoso.user.UserRepository +import com.into.websoso.user.model.MyProfileEntity +import com.into.websoso.user.model.UserFeedsEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class UpdatedGetMyFeedsUseCase @Inject constructor( + private val userRepository: UserRepository, + private val feedRepository: UpdatedFeedRepository, +) { + val myFeedsFlow: Flow> = feedRepository.myFeeds + .map { list -> list.map { it.toDomain() } } + + private var myProfile: MyProfileEntity? = null + private var myId: Long? = null + + suspend operator fun invoke( + lastFeedId: Long = INITIAL_ID, + size: Int = INITIAL_REQUEST_SIZE, + genres: List? = null, + isVisible: Boolean? = null, + isUnVisible: Boolean? = null, + sortCriteria: String? = null, + ): Feeds { + val isFeedRefreshed: Boolean = lastFeedId == INITIAL_ID + val profile = myProfile ?: userRepository.fetchMyProfile().also { myProfile = it } + val myId = myId ?: userRepository.fetchUserInfo().userId.also { myId = it } + + val myFeedsEntity: UserFeedsEntity = userRepository.fetchMyActivities( + lastFeedId = lastFeedId, + size = if (isFeedRefreshed) INITIAL_REQUEST_SIZE else ADDITIONAL_REQUEST_SIZE, + genres = genres?.toTypedArray(), + isVisible = isVisible, + isUnVisible = isUnVisible, + sortCriteria = sortCriteria, + ) + + val convertedFeeds = myFeedsEntity.feeds.map { userFeed -> + userFeed.toFeedEntity(userProfile = profile, userId = myId) + } + + feedRepository.updateMyFeedsCache( + feeds = convertedFeeds, + isRefreshed = isFeedRefreshed, + ) + + return Feeds( + category = "내 활동", + isLoadable = myFeedsEntity.isLoadable, + feeds = emptyList(), + ) + } + + companion object { + private const val INITIAL_ID: Long = 0 + private const val INITIAL_REQUEST_SIZE = 40 + private const val ADDITIONAL_REQUEST_SIZE = 20 + } +} + +private fun UserFeedsEntity.UserFeedEntity.toFeedEntity( + userProfile: MyProfileEntity, + userId: Long, +): FeedEntity { + return FeedEntity( + id = this.feedId, + content = this.feedContent, + createdDate = this.createdDate, + isModified = this.isModified, + isSpoiler = this.isSpoiler, + isPublic = this.isPublic, + isLiked = this.isLiked, + likeCount = this.likeCount, + commentCount = this.commentCount, + isMyFeed = true, + user = FeedEntity.UserEntity( + id = userId, + nickname = userProfile.nickname, + avatarImage = userProfile.avatarImage, + ), + novel = FeedEntity.NovelEntity( + id = this.novelId, + title = this.title, + rating = this.novelRating, + ratingCount = this.novelRatingCount, + ), + images = if (this.thumbnailUrl != null) listOf(this.thumbnailUrl.orEmpty()) else emptyList(), + imageCount = this.imageCount, + relevantCategories = this.relevantCategories, + genreName = this.genre, + userNovelRating = this.userNovelRating, + feedWriterNovelRating = this.feedWriterNovelRating, + ) +} diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt index 5cc3c9bcc..b26567807 100644 --- a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt @@ -1,7 +1,5 @@ package com.into.websoso.feature.feed -import androidx.compose.foundation.layout.Box -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberModalBottomSheetState @@ -11,13 +9,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.into.websoso.feature.feed.model.FeedTab import kotlinx.coroutines.launch +@Deprecated("피드 QA 완료 후 제거 예정") @OptIn(ExperimentalMaterial3Api::class) @Composable fun FeedRoute( @@ -39,44 +36,39 @@ fun FeedRoute( ) var isFilterSheetVisible by remember { mutableStateOf(false) } - Box { - FeedScreen( - uiState = uiState, - bottomSheetState = bottomSheetState, - onTabSelected = viewModel::updateTab, - onSortSelected = viewModel::updateMyFeedSort, - onSosoTypeSelected = viewModel::updateSosoCategory, - onWriteClick = onWriteClick, - onProfileClick = { userId, feedTab -> - onProfileClick( - userId, - feedTab == FeedTab.MY_FEED, - ) - }, - onMoreClick = { }, - onNovelClick = onNovelClick, - onLikeClick = viewModel::updateLike, - onContentClick = onContentClick, - onFilterClick = { - scope.launch { bottomSheetState.show() } - .invokeOnCompletion { isFilterSheetVisible = true } - }, - onApplyFilterClick = { - scope.launch { - viewModel.applyMyFilter(filter = it) - bottomSheetState.hide() - }.invokeOnCompletion { isFilterSheetVisible = false } - }, - isFilterSheetVisible = isFilterSheetVisible, - onFilterCloseClick = { - scope.launch { bottomSheetState.hide() } - .invokeOnCompletion { isFilterSheetVisible = false } - }, - onFirstItemClick = onFirstItemClick, - onSecondItemClick = onSecondItemClick, - onRefreshPull = viewModel::refresh, - ) - - if (uiState.loading) CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } + FeedScreen( + uiState = uiState, + bottomSheetState = bottomSheetState, + onTabSelected = viewModel::updateTab, + onSortSelected = viewModel::updateMyFeedSort, + onSosoTypeSelected = viewModel::updateSosoCategory, + onWriteClick = onWriteClick, + onProfileClick = { userId, feedTab -> + onProfileClick( + userId, + feedTab == FeedTab.MY_FEED, + ) + }, + onNovelClick = onNovelClick, + onLikeClick = viewModel::updateLike, + onContentClick = onContentClick, + onFilterClick = { + scope.launch { bottomSheetState.show() } + .invokeOnCompletion { isFilterSheetVisible = true } + }, + onApplyFilterClick = { + scope.launch { + viewModel.applyMyFilter(filter = it) + bottomSheetState.hide() + }.invokeOnCompletion { isFilterSheetVisible = false } + }, + isFilterSheetVisible = isFilterSheetVisible, + onFilterCloseClick = { + scope.launch { bottomSheetState.hide() } + .invokeOnCompletion { isFilterSheetVisible = false } + }, + onFirstItemClick = onFirstItemClick, + onSecondItemClick = onSecondItemClick, + onRefreshPull = viewModel::refresh, + ) } diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt index 92cf48c23..1e00730fa 100644 --- a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt @@ -1,5 +1,6 @@ package com.into.websoso.feature.feed +import android.annotation.SuppressLint import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -17,7 +18,6 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.SecondaryScrollableTabRow import androidx.compose.material3.SheetState -import androidx.compose.material3.Tab import androidx.compose.material3.TabRowDefaults import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState @@ -46,6 +46,7 @@ import com.into.websoso.feature.feed.model.MyFeedFilter import com.into.websoso.feature.feed.model.SosoFeedType @OptIn(ExperimentalMaterial3Api::class) +@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @Composable internal fun FeedScreen( uiState: FeedUiState, @@ -57,7 +58,6 @@ internal fun FeedScreen( onWriteClick: () -> Unit, onFilterClick: () -> Unit, onProfileClick: (userId: Long, feedTab: FeedTab) -> Unit, - onMoreClick: (feedId: Long) -> Unit, onNovelClick: (novelId: Long) -> Unit, onLikeClick: (feedId: Long) -> Unit, onContentClick: (feedId: Long, isLiked: Boolean) -> Unit, @@ -67,7 +67,7 @@ internal fun FeedScreen( onSecondItemClick: (feedId: Long, isMyFeed: Boolean) -> Unit, onRefreshPull: () -> Unit, ) { - Scaffold(containerColor = White) { + Scaffold(containerColor = White) { _ -> Column(modifier = Modifier.statusBarsPadding()) { FeedTabRow( selectedTab = uiState.selectedTab, @@ -160,14 +160,12 @@ internal fun FeedScreen( currentTab = uiState.selectedTab, feeds = when (uiState.selectedTab) { FeedTab.MY_FEED -> uiState.myFeedData.feeds - FeedTab.SOSO_FEED -> when (uiState.sosoCategory) { SosoFeedType.ALL -> uiState.sosoAllData.feeds SosoFeedType.RECOMMENDED -> uiState.sosoRecommendationData.feeds } }, onProfileClick = onProfileClick, - onMoreClick = onMoreClick, onNovelClick = onNovelClick, onLikeClick = onLikeClick, onContentClick = onContentClick, @@ -212,34 +210,39 @@ private fun FeedTabRow( selectedTabIndex = selectedTab.ordinal, containerColor = White, edgePadding = 0.dp, + minTabWidth = 0.dp, divider = {}, indicator = { TabRowDefaults.SecondaryIndicator( - modifier = Modifier.tabIndicatorOffset( - selectedTabIndex = selectedTab.ordinal, - matchContentSize = true, - ), + modifier = Modifier + .tabIndicatorOffset( + selectedTabIndex = selectedTab.ordinal, + matchContentSize = false, + ) + .padding(horizontal = 8.dp), height = 2.dp, color = Black, ) }, - modifier = Modifier.weight(weight = 1f), + modifier = Modifier + .weight(weight = 1f) + .padding(horizontal = 12.dp), ) { FeedTab.entries.forEach { tab -> - Tab( - selected = selectedTab == tab, - onClick = { - onTabClick(tab) - }, - text = { - Text( - text = tab.title, - style = WebsosoTheme.typography.headline1, - ) - }, - selectedContentColor = Black, - unselectedContentColor = Gray100, - ) + val selected = selectedTab == tab + Box( + modifier = Modifier + .debouncedClickable { onTabClick(tab) } + .padding(horizontal = 8.dp) + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = tab.title, + style = WebsosoTheme.typography.headline1, + color = if (selected) Black else Gray100, + ) + } } } @@ -271,7 +274,6 @@ private fun FeedScreenPreview() { onSortSelected = { }, onSosoTypeSelected = { }, onProfileClick = { _, _ -> }, - onMoreClick = { }, onNovelClick = { }, onLikeClick = { }, onContentClick = { _, _ -> }, diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt index 7bde8a689..1a17b321e 100644 --- a/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt @@ -1,6 +1,5 @@ package com.into.websoso.feature.feed -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.into.websoso.data.feed.repository.FeedRepository @@ -20,258 +19,252 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +@Deprecated("피드 QA 완료 후 제거 예정") @HiltViewModel class FeedViewModel - @Inject - constructor( - private val getFeedsUseCase: GetFeedsUseCase, - private val getMyFeedsUseCase: GetMyFeedsUseCase, - private val feedRepository: FeedRepository, - ) : ViewModel() { - private val _uiState = MutableStateFlow(FeedUiState()) - val uiState = _uiState.asStateFlow() +@Inject +constructor( + private val getFeedsUseCase: GetFeedsUseCase, + private val getMyFeedsUseCase: GetMyFeedsUseCase, + private val feedRepository: FeedRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(FeedUiState()) + val uiState = _uiState.asStateFlow() - init { - fetchNextPage() - } + init { + fetchNextPage() + } - /** - * 내 피드 정렬 순서 변경 (최신순/오래된순) - */ - fun updateMyFeedSort(sort: FeedOrder) { - if (uiState.value.myFeedData.sort == sort) return + /** + * 내 피드 정렬 순서 변경 (최신순/오래된순) + */ + fun updateMyFeedSort(sort: FeedOrder) { + if (uiState.value.myFeedData.sort == sort) return - _uiState.update { - it.copy(myFeedData = FeedSourceData(sort = sort)) - } - fetchNextPage() + _uiState.update { + it.copy(myFeedData = FeedSourceData(sort = sort)) } + fetchNextPage() + } - /** - * 메인 탭 전환 (내 피드 <-> 소소 피드) - */ - fun updateTab(tab: FeedTab) { - _uiState.update { it.copy(selectedTab = tab) } - if (uiState.value.currentData.feeds - .isEmpty() - ) { - fetchNextPage() - } + /** + * 메인 탭 전환 (내 피드 <-> 소소 피드) + */ + fun updateTab(tab: FeedTab) { + _uiState.update { it.copy(selectedTab = tab) } + if (uiState.value.currentData.feeds + .isEmpty() + ) { + fetchNextPage() } + } - /** - * 소소 피드 내 카테고리 전환 (전체 <-> 추천) - */ - fun updateSosoCategory(category: SosoFeedType) { - if (uiState.value.sosoCategory == category) return + /** + * 소소 피드 내 카테고리 전환 (전체 <-> 추천) + */ + fun updateSosoCategory(category: SosoFeedType) { + if (uiState.value.sosoCategory == category) return - _uiState.update { it.copy(sosoCategory = category) } - if (uiState.value.currentData.feeds - .isEmpty() - ) { - fetchNextPage() - } + _uiState.update { it.copy(sosoCategory = category) } + if (uiState.value.currentData.feeds + .isEmpty() + ) { + fetchNextPage() } + } - /** - * 페이징 데이터 로드 (무한 스크롤 공통 로직) - */ - fun fetchNextPage(feedId: Long? = null) { - val state = uiState.value - val current = state.currentData - val lastFeedId = feedId ?: current.lastId + /** + * 페이징 데이터 로드 (무한 스크롤 공통 로직) + */ + fun fetchNextPage(feedId: Long? = null) { + val state = uiState.value + val current = state.currentData + val lastFeedId = feedId ?: current.lastId - if (state.loading || !current.isLoadable) return + if (state.loading || !current.isLoadable) return - _uiState.update { it.copy(loading = true) } + _uiState.update { it.copy(loading = true) } - viewModelScope.launch { - runCatching { - when (state.selectedTab) { - FeedTab.MY_FEED -> getMyFeedsUseCase( - lastFeedId = lastFeedId, - genres = state.currentFilter.selectedGenres.map { it.tag }, - isVisible = state.currentFilter.isVisible, - sortCriteria = state.myFeedData.sort.name - .uppercase(), - isUnVisible = state.currentFilter.isUnVisible, - ) + viewModelScope.launch { + runCatching { + when (state.selectedTab) { + FeedTab.MY_FEED -> getMyFeedsUseCase( + lastFeedId = lastFeedId, + genres = state.currentFilter.selectedGenres.map { it.tag }, + isVisible = state.currentFilter.isVisible, + sortCriteria = state.myFeedData.sort.name + .uppercase(), + isUnVisible = state.currentFilter.isUnVisible, + ) - FeedTab.SOSO_FEED -> getFeedsUseCase( - feedsOption = state.sosoCategory.name.uppercase(), - lastFeedId = lastFeedId, - ) - } - }.onSuccess { result -> - _uiState.update { currentState -> - val source = currentState.currentData - val updatedSource = source.copy( - feeds = (source.feeds + result.feeds.map(Feed::toFeedUiModel)).toImmutableList(), - lastId = result.feeds.lastOrNull()?.id ?: 0, - isLoadable = result.isLoadable, - ) - currentState - .updateCurrentSource(updatedData = updatedSource) - .copy(loading = false, isRefreshing = false) - } - }.onFailure { - _uiState.update { it.copy(loading = false, isRefreshing = false, error = true) } + FeedTab.SOSO_FEED -> getFeedsUseCase( + feedsOption = state.sosoCategory.name.uppercase(), + lastFeedId = lastFeedId, + ) + } + }.onSuccess { result -> + _uiState.update { currentState -> + val source = currentState.currentData + val updatedSource = source.copy( + feeds = (source.feeds + result.feeds.map(Feed::toFeedUiModel)).toImmutableList(), + lastId = result.feeds.lastOrNull()?.id ?: 0, + isLoadable = result.isLoadable, + ) + currentState + .updateCurrentSource(updatedData = updatedSource) + .copy(loading = false, isRefreshing = false) } + }.onFailure { + _uiState.update { it.copy(loading = false, isRefreshing = false, error = true) } } } + } - /** - * 당겨서 새로고침 (데이터 초기화 후 재요청) - */ - fun refresh() { - _uiState.update { state -> - val clearedState = when (state.selectedTab) { - FeedTab.MY_FEED -> state.copy(myFeedData = FeedSourceData(sort = state.myFeedData.sort)) + /** + * 당겨서 새로고침 (데이터 초기화 후 재요청) + */ + fun refresh() { + _uiState.update { state -> + val clearedState = when (state.selectedTab) { + FeedTab.MY_FEED -> state.copy(myFeedData = FeedSourceData(sort = state.myFeedData.sort)) - FeedTab.SOSO_FEED -> if (state.sosoCategory == SosoFeedType.ALL) { - state.copy(sosoAllData = FeedSourceData()) - } else { - state.copy(sosoRecommendationData = FeedSourceData()) - } + FeedTab.SOSO_FEED -> if (state.sosoCategory == SosoFeedType.ALL) { + state.copy(sosoAllData = FeedSourceData()) + } else { + state.copy(sosoRecommendationData = FeedSourceData()) } - clearedState.copy(isRefreshing = true) } - fetchNextPage() + clearedState.copy(isRefreshing = true) } + fetchNextPage() + } - /** - * 내 피드 장르/공개여부 필터 적용 - */ - fun applyMyFilter(filter: MyFeedFilter) { - _uiState.update { - it.copy(currentFilter = filter, myFeedData = FeedSourceData()) - } - fetchNextPage() + /** + * 내 피드 장르/공개여부 필터 적용 + */ + fun applyMyFilter(filter: MyFeedFilter) { + _uiState.update { + it.copy(currentFilter = filter, myFeedData = FeedSourceData()) } + fetchNextPage() + } - /** - * 좋아요 업데이트 (낙관적 업데이트 미적용, 서버 응답 후 반영) - */ - fun updateLike(selectedFeedId: Long) { - val targetFeed = uiState.value.currentData.feeds - .find { it.id == selectedFeedId } ?: return - val currentIsLiked = targetFeed.isLiked + /** + * 좋아요 업데이트 (낙관적 업데이트 미적용, 서버 응답 후 반영) + */ + fun updateLike(selectedFeedId: Long) { + val targetFeed = uiState.value.currentData.feeds + .find { it.id == selectedFeedId } ?: return + val currentIsLiked = targetFeed.isLiked - viewModelScope.launch { - runCatching { - feedRepository.saveLike(currentIsLiked, selectedFeedId) - }.onSuccess { - _uiState.update { state -> - val nextIsLiked = !currentIsLiked - val nextCount = - if (nextIsLiked) targetFeed.likeCount + 1 else targetFeed.likeCount - 1 + viewModelScope.launch { + runCatching { + feedRepository.saveLike(currentIsLiked, selectedFeedId) + }.onSuccess { + _uiState.update { state -> + val nextIsLiked = !currentIsLiked + val nextCount = + if (nextIsLiked) targetFeed.likeCount + 1 else targetFeed.likeCount - 1 - val updatedFeeds = state.currentData.feeds - .map { feed -> - if (feed.id == selectedFeedId) { - feed.copy( - isLiked = nextIsLiked, - likeCount = nextCount, - ) - } else { - feed - } - }.toImmutableList() + val updatedFeeds = state.currentData.feeds + .map { feed -> + if (feed.id == selectedFeedId) { + feed.copy( + isLiked = nextIsLiked, + likeCount = nextCount, + ) + } else { + feed + } + }.toImmutableList() - state.updateCurrentSource(state.currentData.copy(feeds = updatedFeeds)) - } - }.onFailure { - _uiState.update { it.copy(error = true) } + state.updateCurrentSource(state.currentData.copy(feeds = updatedFeeds)) } + }.onFailure { + _uiState.update { it.copy(error = true) } } } + } - /** - * 스포일러 신고 (해당 아이템을 즉시 스포일러 상태로 변경) - */ - fun updateReportedSpoilerFeed(feedId: Long) { - viewModelScope.launch { - runCatching { - feedRepository.saveSpoilerFeed(feedId) - }.onSuccess { - _uiState.update { state -> - val updatedFeeds = state.currentData.feeds - .map { feed -> - if (feed.id == feedId) feed.copy(isSpoiler = true) else feed - }.toImmutableList() + /** + * 스포일러 신고 (해당 아이템을 즉시 스포일러 상태로 변경) + */ + fun updateReportedSpoilerFeed(feedId: Long) { + viewModelScope.launch { + runCatching { + feedRepository.saveSpoilerFeed(feedId) + }.onSuccess { + _uiState.update { state -> + val updatedFeeds = state.currentData.feeds + .map { feed -> + if (feed.id == feedId) feed.copy(isSpoiler = true) else feed + }.toImmutableList() - state.updateCurrentSource(state.currentData.copy(feeds = updatedFeeds)) - } - }.onFailure { - _uiState.update { it.copy(error = true) } + state.updateCurrentSource(state.currentData.copy(feeds = updatedFeeds)) } + }.onFailure { + _uiState.update { it.copy(error = true) } } } + } - /** - * 부적절한 피드 신고 (리스트에서 즉시 제거) - */ - fun updateReportedImpertinenceFeed(feedId: Long) { - viewModelScope.launch { - runCatching { - feedRepository.saveImpertinenceFeed(feedId) - }.onSuccess { - _uiState.update { state -> - val filteredFeeds = - state.currentData.feeds - .filter { it.id != feedId } - .toImmutableList() - state.updateCurrentSource(state.currentData.copy(feeds = filteredFeeds)) - } - }.onFailure { - _uiState.update { it.copy(error = true) } + /** + * 부적절한 피드 신고 (리스트에서 즉시 제거) + */ + fun updateReportedImpertinenceFeed(feedId: Long) { + viewModelScope.launch { + runCatching { + feedRepository.saveImpertinenceFeed(feedId) + }.onSuccess { + _uiState.update { state -> + val filteredFeeds = + state.currentData.feeds + .filter { it.id != feedId } + .toImmutableList() + state.updateCurrentSource(state.currentData.copy(feeds = filteredFeeds)) } + }.onFailure { + _uiState.update { it.copy(error = true) } } } + } - /** - * 피드 삭제 로직 - */ - fun updateRemovedFeed(feedId: Long) { - val targetFeed = uiState.value.currentData.feeds - .find { it.id == feedId } ?: return - - _uiState.update { it.copy(loading = true) } + /** + * 피드 삭제 로직 + */ + fun updateRemovedFeed(feedId: Long) { + _uiState.update { it.copy(loading = true) } - viewModelScope.launch { - runCatching { - feedRepository.saveRemovedFeed( - feedId = feedId, - novelId = targetFeed.novel?.id, - content = targetFeed.content, - ) - }.onSuccess { - _uiState.update { state -> - val filteredFeeds = - state.currentData.feeds - .filter { it.id != feedId } - .toImmutableList() - val updatedData = state.currentData.copy(feeds = filteredFeeds) - state.updateCurrentSource(updatedData).copy(loading = false) - } - }.onFailure { - _uiState.update { it.copy(loading = false, error = true) } + viewModelScope.launch { + runCatching { + feedRepository.saveRemovedFeed(feedId = feedId) + }.onSuccess { + _uiState.update { state -> + val filteredFeeds = + state.currentData.feeds + .filter { it.id != feedId } + .toImmutableList() + val updatedData = state.currentData.copy(feeds = filteredFeeds) + state.updateCurrentSource(updatedData).copy(loading = false) } + }.onFailure { + _uiState.update { it.copy(loading = false, error = true) } } } + } - /** - * 모든 피드 데이터를 초기 상태로 리셋하고 첫 페이지를 다시 불러옴 - */ - fun resetFeedsToInitial() { - _uiState.update { state -> - state.copy( - myFeedData = FeedSourceData(sort = state.myFeedData.sort), - sosoAllData = FeedSourceData(), - sosoRecommendationData = FeedSourceData(), - ) - } - - fetchNextPage(feedId = 0L) + /** + * 모든 피드 데이터를 초기 상태로 리셋하고 첫 페이지를 다시 불러옴 + */ + fun resetFeedsToInitial() { + _uiState.update { state -> + state.copy( + myFeedData = FeedSourceData(sort = state.myFeedData.sort), + sosoAllData = FeedSourceData(), + sosoRecommendationData = FeedSourceData(), + ) } + + fetchNextPage(feedId = 0L) } +} diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/UpdateFeedRoute.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/UpdateFeedRoute.kt new file mode 100644 index 000000000..9f85abd89 --- /dev/null +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/UpdateFeedRoute.kt @@ -0,0 +1,74 @@ +package com.into.websoso.feature.feed + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.into.websoso.feature.feed.model.FeedTab +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UpdateFeedRoute( + onWriteClick: () -> Unit, + onProfileClick: (userId: Long, isMyFeed: Boolean) -> Unit, + onNovelClick: (novelId: Long) -> Unit, + onContentClick: (feedId: Long, isLiked: Boolean) -> Unit, + onFirstItemClick: (feedId: Long, isMyFeed: Boolean) -> Unit, + onSecondItemClick: (feedId: Long, isMyFeed: Boolean) -> Unit, + viewModel: UpdatedFeedViewModel = hiltViewModel(), +) { + val scope = rememberCoroutineScope() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val bottomSheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { newValue -> + newValue != SheetValue.Hidden + }, + ) + var isFilterSheetVisible by remember { mutableStateOf(false) } + + FeedScreen( + uiState = uiState, + bottomSheetState = bottomSheetState, + onTabSelected = viewModel::updateTab, + onSortSelected = viewModel::updateMyFeedSort, + onSosoTypeSelected = viewModel::updateSosoCategory, + onWriteClick = onWriteClick, + onProfileClick = { userId, feedTab -> + onProfileClick( + userId, + feedTab == FeedTab.MY_FEED, + ) + }, + onNovelClick = onNovelClick, + onLikeClick = viewModel::updateLike, + onContentClick = onContentClick, + onFilterClick = { + scope.launch { bottomSheetState.show() } + .invokeOnCompletion { isFilterSheetVisible = true } + }, + onApplyFilterClick = { + scope.launch { + viewModel.applyMyFilter(filter = it) + bottomSheetState.hide() + }.invokeOnCompletion { isFilterSheetVisible = false } + }, + + isFilterSheetVisible = isFilterSheetVisible, + onFilterCloseClick = { + scope.launch { bottomSheetState.hide() } + .invokeOnCompletion { isFilterSheetVisible = false } + }, + onFirstItemClick = onFirstItemClick, + onSecondItemClick = onSecondItemClick, + onRefreshPull = viewModel::refresh, + ) +} diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/UpdatedFeedViewModel.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/UpdatedFeedViewModel.kt new file mode 100644 index 000000000..8615bd8e0 --- /dev/null +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/UpdatedFeedViewModel.kt @@ -0,0 +1,178 @@ +package com.into.websoso.feature.feed + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.into.websoso.data.feed.repository.UpdatedFeedRepository +import com.into.websoso.feature.feed.model.FeedOrder +import com.into.websoso.feature.feed.model.FeedTab +import com.into.websoso.feature.feed.model.MyFeedFilter +import com.into.websoso.feature.feed.model.SosoFeedType +import com.into.websoso.feature.feed.model.toFeedUiModel +import com.into.websoso.feed.UpdatedGetFeedsUseCase +import com.into.websoso.feed.UpdatedGetMyFeedsUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class UpdatedFeedViewModel @Inject constructor( + private val getFeedsUseCase: UpdatedGetFeedsUseCase, + private val getMyFeedsUseCase: UpdatedGetMyFeedsUseCase, + private val feedRepository: UpdatedFeedRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(FeedUiState()) + val uiState = _uiState.asStateFlow() + + init { + collectFeedFlows() + fetchNextPage() + } + + private fun collectFeedFlows() { + viewModelScope.launch { + getFeedsUseCase.sosoAllFlow.collect { feeds -> + val uiFeeds = feeds.map { it.toFeedUiModel() }.toImmutableList() + _uiState.update { state -> + val updatedAllData = state.sosoAllData.copy(feeds = uiFeeds) + if (state.selectedTab == FeedTab.SOSO_FEED && state.sosoCategory == SosoFeedType.ALL) { + state.copy(sosoAllData = updatedAllData).updateCurrentSource(updatedAllData) + } else { + state.copy(sosoAllData = updatedAllData) + } + } + } + } + viewModelScope.launch { + getFeedsUseCase.sosoRecommendedFlow.collect { feeds -> + val uiFeeds = feeds.map { it.toFeedUiModel() }.toImmutableList() + _uiState.update { state -> + val updatedRecData = state.sosoRecommendationData.copy(feeds = uiFeeds) + if (state.selectedTab == FeedTab.SOSO_FEED && state.sosoCategory == SosoFeedType.RECOMMENDED) { + state.copy(sosoRecommendationData = updatedRecData) + .updateCurrentSource(updatedRecData) + } else { + state.copy(sosoRecommendationData = updatedRecData) + } + } + } + } + viewModelScope.launch { + getMyFeedsUseCase.myFeedsFlow.collect { feeds -> + val uiFeeds = feeds.map { it.toFeedUiModel() }.toImmutableList() + _uiState.update { state -> + val updatedMyData = state.myFeedData.copy(feeds = uiFeeds) + if (state.selectedTab == FeedTab.MY_FEED) { + state.copy(myFeedData = updatedMyData).updateCurrentSource(updatedMyData) + } else { + state.copy(myFeedData = updatedMyData) + } + } + } + } + } + + fun fetchNextPage(feedId: Long? = null) { + val state = uiState.value + val current = state.currentData + val lastFeedId = feedId ?: current.lastId + + if (state.loading || (!current.isLoadable && lastFeedId != 0L)) return + + _uiState.update { it.copy(loading = true) } + + viewModelScope.launch { + runCatching { + when (state.selectedTab) { + + FeedTab.MY_FEED -> { + getMyFeedsUseCase( + lastFeedId = lastFeedId, + genres = state.currentFilter.selectedGenres.map { it.tag }, + isVisible = state.currentFilter.isVisible, + sortCriteria = state.myFeedData.sort.name.uppercase(), + isUnVisible = state.currentFilter.isUnVisible, + ) + } + + FeedTab.SOSO_FEED -> getFeedsUseCase( + feedsOption = state.sosoCategory.name.uppercase(), + lastFeedId = lastFeedId, + ) + } + }.onSuccess { result -> + _uiState.update { currentState -> + val updatedSource = currentState.currentData.copy( + lastId = result.isLoadable.let { + if (it) result.feeds.lastOrNull()?.id ?: 0 else 0 + }, + isLoadable = result.isLoadable, + ) + currentState.updateCurrentSource(updatedSource) + .copy(loading = false, isRefreshing = false) + } + }.onFailure { + _uiState.update { it.copy(loading = false, isRefreshing = false, error = true) } + } + } + } + + /** + * [새로고침] 기존 데이터를 지우지 않고 isRefreshing만 켠 후 재요청 + * 데이터 교체는 Repository가 Flow를 방출할 때 자연스럽게 이루어짐 + */ + fun refresh() { + _uiState.update { it.copy(isRefreshing = true) } + fetchNextPage(feedId = 0L) + } + + fun updateLike(selectedFeedId: Long) = feedRepository.toggleLikeLocal(selectedFeedId) + + fun updateRemovedFeed(feedId: Long) { + _uiState.update { it.copy(loading = true) } + viewModelScope.launch { + feedRepository.saveRemovedFeed(feedId) + _uiState.update { it.copy(loading = false) } + } + } + + fun updateReportedSpoilerFeed(feedId: Long) { + viewModelScope.launch { feedRepository.saveSpoilerFeed(feedId) } + } + + fun updateReportedImpertinenceFeed(feedId: Long) { + viewModelScope.launch { feedRepository.saveImpertinenceFeed(feedId) } + } + + override fun onCleared() { + super.onCleared() + feedRepository.syncDirtyFeeds() + } + + // --- 기타 탭/필터 로직 --- + fun updateMyFeedSort(sort: FeedOrder) { + if (uiState.value.myFeedData.sort == sort) return + _uiState.update { it.copy(myFeedData = FeedSourceData(sort = sort)) } + fetchNextPage(feedId = 0L) + } + + fun updateTab(tab: FeedTab) { + _uiState.update { it.copy(selectedTab = tab) } + if (uiState.value.currentData.feeds.isEmpty()) fetchNextPage(feedId = 0L) + } + + fun updateSosoCategory(category: SosoFeedType) { + if (uiState.value.sosoCategory == category) return + _uiState.update { it.copy(sosoCategory = category) } + if (uiState.value.currentData.feeds.isEmpty()) fetchNextPage(feedId = 0L) + } + + fun applyMyFilter(filter: MyFeedFilter) { + _uiState.update { it.copy(currentFilter = filter, myFeedData = FeedSourceData()) } + fetchNextPage(feedId = 0L) + } +} diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedMoreMenu.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedMoreMenu.kt index f482da5ec..19bb670b2 100644 --- a/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedMoreMenu.kt +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedMoreMenu.kt @@ -37,7 +37,7 @@ internal fun FeedMoreMenu( onDismissRequest = onDismissRequest, alignment = Alignment.TopEnd, properties = PopupProperties(focusable = true), - offset = IntOffset(x = 0, y = 50), + offset = IntOffset(x = 0, y = 140), ) { val contents = if (isMyFeed) persistentListOf("수정하기", "삭제하기") else persistentListOf("스포일러 신고", "부적절한 표현 신고") @@ -54,7 +54,7 @@ internal fun FeedMoreMenu( color = White, shape = RoundedCornerShape(size = 12.dp), ) - .width(IntrinsicSize.Max), + .width(width = 180.dp), ) { contents.forEachIndexed { index, label -> Text( diff --git a/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedSection.kt b/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedSection.kt index 6e2fc886b..c62f4dc10 100644 --- a/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedSection.kt +++ b/feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedSection.kt @@ -59,7 +59,6 @@ internal fun FeedSection( currentTab: FeedTab, feeds: ImmutableList, onProfileClick: (userId: Long, feedTab: FeedTab) -> Unit, - onMoreClick: (feedId: Long) -> Unit, onNovelClick: (novelId: Long) -> Unit, onLikeClick: (feedId: Long) -> Unit, onContentClick: (feedId: Long, isLiked: Boolean) -> Unit, @@ -73,7 +72,7 @@ internal fun FeedSection( onRefresh = onRefreshPull, modifier = Modifier.fillMaxSize(), ) { - if (feeds.isEmpty()) { + if (feeds.isEmpty() && !isRefreshing) { FeedEmptyCase() } else { LazyColumn(modifier = Modifier.padding(horizontal = 20.dp)) { @@ -81,7 +80,6 @@ internal fun FeedSection( FeedItem( feed = feed, currentTab = currentTab, - onMoreClick = onMoreClick, onProfileClick = onProfileClick, onNovelClick = onNovelClick, onLikeClick = onLikeClick, @@ -102,7 +100,6 @@ private fun FeedItem( feed: FeedUiModel, currentTab: FeedTab, onProfileClick: (userId: Long, feedTab: FeedTab) -> Unit, - onMoreClick: (feedId: Long) -> Unit, onNovelClick: (novelId: Long) -> Unit, onLikeClick: (feedId: Long) -> Unit, onContentClick: (feedId: Long, isLiked: Boolean) -> Unit, @@ -167,28 +164,24 @@ private fun FeedItem( ) } } + Box { Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.ic_more), + imageVector = ImageVector.vectorResource(id = R.drawable.ic_three_dots), contentDescription = null, tint = Gray200, modifier = Modifier .size(size = 14.dp) - .debouncedClickable { - isMenuExpanded = true - onMoreClick(feed.id) - }, + .debouncedClickable { isMenuExpanded = true }, ) if (isMenuExpanded) { FeedMoreMenu( isMyFeed = feed.isMyFeed, onDismissRequest = { isMenuExpanded = false }, - // TODO: 위 아래 itemClick 모두 로직을 feed.isMyFeed 를 사용하도록 수정 읽고 나면 제거 onFirstItemClick = { onFirstItemClick(feed.id, feed.isMyFeed) }, - // TODO: 여기 소소 피드에서 내 피드 클릭하면 부적절한 표현이 사용되었나요 라고 떠서 수정함 읽고 나면 제거 onSecondItemClick = { onSecondItemClick(feed.id, feed.isMyFeed) }, @@ -262,8 +255,8 @@ private fun FeedItem( Spacer(modifier = Modifier.height(height = 10.dp)) - when (currentTab) { - FeedTab.MY_FEED -> { + when (feed.isPublic) { + false -> { Row( horizontalArrangement = Arrangement.spacedBy(space = 6.dp), modifier = Modifier @@ -284,7 +277,7 @@ private fun FeedItem( } } - FeedTab.SOSO_FEED -> { + true -> { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(space = 18.dp), @@ -301,6 +294,7 @@ private fun FeedItem( imageVector = ImageVector.vectorResource( id = if (feed.isLiked) R.drawable.ic_thumb_up_on else R.drawable.ic_thumb_up, ), + tint = if (feed.isLiked) Black else Gray200, contentDescription = null, ) @@ -411,18 +405,23 @@ private fun FeedNovelInfo( @Composable private fun FeedSectionPreview() { WebsosoTheme { - FeedSection( - feeds = persistentListOf(FeedUiModel()), - currentTab = FeedTab.SOSO_FEED, - onProfileClick = { _, _ -> }, - onMoreClick = { }, - onNovelClick = { }, - onLikeClick = { }, - onContentClick = { _, _ -> }, - onFirstItemClick = { _, _ -> }, - onSecondItemClick = { _, _ -> }, - onRefreshPull = { }, - isRefreshing = false, - ) + Box( + modifier = Modifier + .fillMaxSize() + .background(color = White), + ) { + FeedSection( + feeds = persistentListOf(FeedUiModel()), + currentTab = FeedTab.SOSO_FEED, + onProfileClick = { _, _ -> }, + onNovelClick = { }, + onLikeClick = { }, + onContentClick = { _, _ -> }, + onFirstItemClick = { _, _ -> }, + onSecondItemClick = { _, _ -> }, + onRefreshPull = { }, + isRefreshing = false, + ) + } } } diff --git a/feature/library/src/main/java/com/into/websoso/feature/library/LibraryScreen.kt b/feature/library/src/main/java/com/into/websoso/feature/library/LibraryScreen.kt index 1ac231bf2..a02070759 100644 --- a/feature/library/src/main/java/com/into/websoso/feature/library/LibraryScreen.kt +++ b/feature/library/src/main/java/com/into/websoso/feature/library/LibraryScreen.kt @@ -150,7 +150,7 @@ private fun LibraryScreen( LibraryFilterTopBar( libraryFilterUiModel = uiState.libraryFilterUiModel, - totalCount = novels.itemCount, + totalCount = uiState.novelTotalCount, isGrid = uiState.isGrid, onFilterClick = onFilterClick, onSortClick = onSortClick, @@ -166,7 +166,7 @@ private fun LibraryScreen( ) { when { novels.itemCount == 0 && - novels.loadState.refresh !is Loading -> { + novels.loadState.refresh !is Loading -> { if (uiState.libraryFilterUiModel.isFilterApplied) { LibraryFilterEmptyView() } else { diff --git a/feature/library/src/main/java/com/into/websoso/feature/library/LibraryViewModel.kt b/feature/library/src/main/java/com/into/websoso/feature/library/LibraryViewModel.kt index 6a955a319..7026ef36a 100644 --- a/feature/library/src/main/java/com/into/websoso/feature/library/LibraryViewModel.kt +++ b/feature/library/src/main/java/com/into/websoso/feature/library/LibraryViewModel.kt @@ -20,6 +20,7 @@ import com.into.websoso.feature.library.model.LibraryFilterUiModel import com.into.websoso.feature.library.model.LibraryUiState import com.into.websoso.feature.library.model.NovelUiModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -33,120 +34,146 @@ import javax.inject.Inject @HiltViewModel class LibraryViewModel - @Inject - constructor( - libraryRepository: LibraryRepository, - private val filterRepository: FilterRepository, - ) : ViewModel() { - private val _uiState = MutableStateFlow(LibraryUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - private val _tempFilterUiState = MutableStateFlow(uiState.value.libraryFilterUiModel) - val tempFilterUiState = _tempFilterUiState.asStateFlow() - - private val _scrollToTopEvent = Channel(Channel.BUFFERED) - val scrollToTopEvent: Flow = _scrollToTopEvent.receiveAsFlow() - - val novels: Flow> = - libraryRepository.libraryFlow +@Inject +constructor( + private val libraryRepository: LibraryRepository, + private val filterRepository: FilterRepository, +) : ViewModel() { + private var pagingJob: Job? = null + + private val _uiState = MutableStateFlow(LibraryUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _tempFilterUiState = MutableStateFlow(uiState.value.libraryFilterUiModel) + val tempFilterUiState = _tempFilterUiState.asStateFlow() + + private val _scrollToTopEvent = Channel(Channel.BUFFERED) + val scrollToTopEvent: Flow = _scrollToTopEvent.receiveAsFlow() + + val novels: MutableStateFlow> = MutableStateFlow(PagingData.empty()) + + init { + loadLibrary() + loadNovelsCount() + updateLibraryFilter() + } + + private fun loadNovelsCount() { + viewModelScope.launch { + libraryRepository.novelTotalCount.collect { result -> + _uiState.update { it.copy(novelTotalCount = result) } + } + } + } + + private fun loadLibrary() { + pagingJob?.cancel() + + pagingJob = viewModelScope.launch { + libraryRepository.getLibraryFlow() .map { pagingData -> pagingData.map(NovelEntity::toUiModel) } .cachedIn(viewModelScope) - - init { - updateLibraryFilter() + .collect { result -> + novels.update { result } + } } + } - private fun updateLibraryFilter() { - viewModelScope.launch { - filterRepository.filterFlow.collect { filter -> - _uiState.update { uiState -> - uiState.copy( - libraryFilterUiModel = uiState.libraryFilterUiModel.copy( - sortCriteria = SortCriteria.from(filter.sortCriteria), - isInterested = filter.isInterested, - readStatuses = filter.readStatuses.toReadStatuses(), - attractivePoints = filter.attractivePoints.toAttractivePoints(), - novelRating = NovelRating.from(filter.novelRating), - ), - ) - } + fun refresh() { + loadLibrary() + } + + private fun updateLibraryFilter() { + viewModelScope.launch { + filterRepository.filterFlow.collect { filter -> + _uiState.update { uiState -> + uiState.copy( + libraryFilterUiModel = uiState.libraryFilterUiModel.copy( + sortCriteria = SortCriteria.from(filter.sortCriteria), + isInterested = filter.isInterested, + readStatuses = filter.readStatuses.toReadStatuses(), + attractivePoints = filter.attractivePoints.toAttractivePoints(), + novelRating = NovelRating.from(filter.novelRating), + ), + ) } } } + } - fun updateViewType() { - _uiState.update { - it.copy(isGrid = !it.isGrid) - } + fun updateViewType() { + _uiState.update { + it.copy(isGrid = !it.isGrid) } + } - fun resetScrollPosition() { - viewModelScope.launch { - _scrollToTopEvent.send(Unit) - } + fun resetScrollPosition() { + viewModelScope.launch { + _scrollToTopEvent.send(Unit) } + } - fun updateSortType() { - val current = uiState.value.libraryFilterUiModel.sortCriteria - val updatedSortCriteria = when (current) { - SortCriteria.RECENT -> SortCriteria.OLD - SortCriteria.OLD -> SortCriteria.RECENT - } + fun updateSortType() { + val current = uiState.value.libraryFilterUiModel.sortCriteria + val updatedSortCriteria = when (current) { + SortCriteria.RECENT -> SortCriteria.OLD + SortCriteria.OLD -> SortCriteria.RECENT + } - viewModelScope.launch { - filterRepository.updateFilter( - sortCriteria = updatedSortCriteria.key, - ) - } + viewModelScope.launch { + filterRepository.updateFilter( + sortCriteria = updatedSortCriteria.key, + ) } + } - fun updateInterestedNovels() { - val updatedInterested = !uiState.value.libraryFilterUiModel.isInterested + fun updateInterestedNovels() { + val updatedInterested = !uiState.value.libraryFilterUiModel.isInterested - viewModelScope.launch { - filterRepository.updateFilter( - isInterested = updatedInterested, - ) - } + viewModelScope.launch { + filterRepository.updateFilter( + isInterested = updatedInterested, + ) } + } - fun updateMyLibraryFilter() { - _tempFilterUiState.update { - uiState.value.libraryFilterUiModel - } + fun updateMyLibraryFilter() { + _tempFilterUiState.update { + uiState.value.libraryFilterUiModel } + } - fun updateReadStatus(readStatus: ReadStatus) { - _tempFilterUiState.update { - it.copy(readStatuses = it.readStatuses.set(readStatus)) - } + fun updateReadStatus(readStatus: ReadStatus) { + _tempFilterUiState.update { + it.copy(readStatuses = it.readStatuses.set(readStatus)) } + } - fun updateAttractivePoints(attractivePoint: AttractivePoint) { - _tempFilterUiState.update { - it.copy(attractivePoints = it.attractivePoints.set(attractivePoint)) - } + fun updateAttractivePoints(attractivePoint: AttractivePoint) { + _tempFilterUiState.update { + it.copy(attractivePoints = it.attractivePoints.set(attractivePoint)) } + } - fun updateRating(rating: Rating) { - _tempFilterUiState.update { - it.copy(novelRating = it.novelRating.set(rating)) - } + fun updateRating(rating: Rating) { + _tempFilterUiState.update { + it.copy(novelRating = it.novelRating.set(rating)) } + } - fun resetFilter() { - _tempFilterUiState.update { - LibraryFilterUiModel() - } + fun resetFilter() { + _tempFilterUiState.update { + LibraryFilterUiModel() } + } - fun searchFilteredNovels() { - viewModelScope.launch { - filterRepository.updateFilter( - readStatuses = _tempFilterUiState.value.readStatuses.selectedKeys, - attractivePoints = _tempFilterUiState.value.attractivePoints.selectedKeys, - novelRating = _tempFilterUiState.value.novelRating.rating.value, - ) - } + fun searchFilteredNovels() { + viewModelScope.launch { + filterRepository.updateFilter( + readStatuses = _tempFilterUiState.value.readStatuses.selectedKeys, + attractivePoints = _tempFilterUiState.value.attractivePoints.selectedKeys, + novelRating = _tempFilterUiState.value.novelRating.rating.value, + ) } } +} diff --git a/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryFilterEmptyView.kt b/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryFilterEmptyView.kt index 9be11fc73..d9183a817 100644 --- a/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryFilterEmptyView.kt +++ b/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryFilterEmptyView.kt @@ -40,7 +40,7 @@ internal fun LibraryFilterEmptyView() { Spacer(modifier = Modifier.height(8.dp)) Text( text = "해당하는 작품이 없어요\n" + - "검색의 범위를 더 넓혀보세요", + "검색의 범위를 더 넓혀보세요", style = WebsosoTheme.typography.body1, color = Gray200, textAlign = TextAlign.Center, diff --git a/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryGridListItem.kt b/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryGridListItem.kt index 3e006ebf3..ffd9a9e91 100644 --- a/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryGridListItem.kt +++ b/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryGridListItem.kt @@ -2,7 +2,6 @@ package com.into.websoso.feature.library.component import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -28,6 +27,7 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.into.websoso.core.common.extensions.debouncedClickable import com.into.websoso.core.designsystem.component.NetworkImage import com.into.websoso.core.designsystem.theme.Black import com.into.websoso.core.designsystem.theme.Gray200 @@ -59,7 +59,7 @@ internal fun NovelGridListItem( modifier = modifier .width(itemSize.width) .wrapContentHeight() - .clickable { onItemClick() }, + .debouncedClickable { onItemClick() }, verticalArrangement = Arrangement.spacedBy(6.dp), ) { NovelGridThumbnail( @@ -139,7 +139,8 @@ private fun ReadStatusBadge( .background( color = readStatusUiModel.backgroundColor, shape = RoundedCornerShape(4.dp), - ).padding(vertical = 4.dp), + ) + .padding(vertical = 4.dp), contentAlignment = Alignment.Center, ) { Text( diff --git a/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryListItem.kt b/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryListItem.kt index 112f46656..be9d0e548 100644 --- a/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryListItem.kt +++ b/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryListItem.kt @@ -2,7 +2,6 @@ package com.into.websoso.feature.library.component import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -34,6 +33,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil.compose.AsyncImage +import com.into.websoso.core.common.extensions.debouncedClickable import com.into.websoso.core.designsystem.theme.Black import com.into.websoso.core.designsystem.theme.Gray200 import com.into.websoso.core.designsystem.theme.Gray300 @@ -72,7 +72,7 @@ internal fun LibraryListItem( Column( modifier = modifier .fillMaxWidth() - .clickable { onClick() }, + .debouncedClickable { onClick() }, verticalArrangement = Arrangement.spacedBy(8.dp), ) { Row( @@ -162,7 +162,8 @@ private fun ReadStatusBadge( .background( color = it.backgroundColor, shape = RoundedCornerShape(8.dp), - ).padding(vertical = 4.dp), + ) + .padding(vertical = 4.dp), contentAlignment = Alignment.Center, ) { Text( diff --git a/feature/library/src/main/java/com/into/websoso/feature/library/component/LibrayFilterTopBar.kt b/feature/library/src/main/java/com/into/websoso/feature/library/component/LibrayFilterTopBar.kt index 55d04de19..977372eb3 100644 --- a/feature/library/src/main/java/com/into/websoso/feature/library/component/LibrayFilterTopBar.kt +++ b/feature/library/src/main/java/com/into/websoso/feature/library/component/LibrayFilterTopBar.kt @@ -3,7 +3,6 @@ package com.into.websoso.feature.library.component import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -29,6 +28,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp +import com.into.websoso.core.common.extensions.debouncedClickable import com.into.websoso.core.designsystem.theme.Black import com.into.websoso.core.designsystem.theme.Gray200 import com.into.websoso.core.designsystem.theme.Gray300 @@ -45,7 +45,7 @@ import com.into.websoso.feature.library.model.LibraryFilterUiModel @Composable internal fun LibraryFilterTopBar( libraryFilterUiModel: LibraryFilterUiModel, - totalCount: Int, + totalCount: Long, onFilterClick: () -> Unit, onSortClick: () -> Unit, isGrid: Boolean, @@ -137,7 +137,7 @@ private fun NovelFilterChip( shape = RoundedCornerShape(20.dp), modifier = Modifier .defaultMinSize(minHeight = 32.dp) - .clickable(onClick = onClick), + .debouncedClickable(onClick = onClick), border = if (!isSelected) BorderStroke(1.dp, Gray70) else null, ) { Row( @@ -164,7 +164,7 @@ private fun NovelFilterChip( @Composable private fun NovelFilterStatusBar( - totalCount: Int, + totalCount: Long, sortCriteria: SortCriteria, isGrid: Boolean, onSortClick: () -> Unit, diff --git a/feature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetButtons.kt b/feature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetButtons.kt index a3db9ae6d..de1a18d06 100644 --- a/feature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetButtons.kt +++ b/feature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetButtons.kt @@ -1,7 +1,6 @@ package com.into.websoso.feature.library.filter.component import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.IntrinsicSize @@ -20,6 +19,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.into.websoso.core.common.extensions.debouncedClickable import com.into.websoso.core.designsystem.theme.Gray300 import com.into.websoso.core.designsystem.theme.Gray50 import com.into.websoso.core.designsystem.theme.Primary100 @@ -44,7 +44,7 @@ internal fun LibraryFilterBottomSheetButtons( modifier = Modifier .fillMaxHeight() .background(color = Gray50) - .clickable { onResetClick() } + .debouncedClickable { onResetClick() } .padding( vertical = 20.dp, horizontal = 34.dp, @@ -66,7 +66,7 @@ internal fun LibraryFilterBottomSheetButtons( modifier = Modifier .fillMaxHeight() .background(color = Primary100) - .clickable { onFilterSearchClick() } + .debouncedClickable { onFilterSearchClick() } .padding(vertical = 20.dp) .weight(weight = 1f), ) { diff --git a/feature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetClickableItem.kt b/feature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetClickableItem.kt index 0883c6195..03741959b 100644 --- a/feature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetClickableItem.kt +++ b/feature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetClickableItem.kt @@ -1,7 +1,6 @@ package com.into.websoso.feature.library.filter.component import androidx.annotation.DrawableRes -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height @@ -17,6 +16,7 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.into.websoso.core.common.extensions.debouncedClickable import com.into.websoso.core.designsystem.theme.Gray100 import com.into.websoso.core.designsystem.theme.Gray300 import com.into.websoso.core.designsystem.theme.Primary100 @@ -35,7 +35,7 @@ internal fun LibraryFilterBottomSheetClickableItem( Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - .clickable(onClick = onClick) + .debouncedClickable(onClick = onClick) .padding(horizontal = horizontalPadding), ) { Icon( diff --git a/feature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetHeader.kt b/feature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetHeader.kt index ad32169dc..dacfb7cce 100644 --- a/feature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetHeader.kt +++ b/feature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetHeader.kt @@ -1,6 +1,5 @@ package com.into.websoso.feature.library.filter.component -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -15,6 +14,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.into.websoso.core.common.extensions.debouncedClickable import com.into.websoso.core.designsystem.theme.Gray200 import com.into.websoso.core.designsystem.theme.WebsosoTheme import com.into.websoso.core.resource.R.drawable.ic_cancel_modal @@ -38,7 +38,7 @@ internal fun LibraryFilterBottomSheetHeader( Box( modifier = Modifier .padding(vertical = 20.dp) - .clickable { + .debouncedClickable { onDismissRequest() }, ) { diff --git a/feature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetNovelRatingGrid.kt b/feature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetNovelRatingGrid.kt index 642a8fa01..6476f9825 100644 --- a/feature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetNovelRatingGrid.kt +++ b/feature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetNovelRatingGrid.kt @@ -2,7 +2,6 @@ package com.into.websoso.feature.library.filter.component import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -16,6 +15,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.into.websoso.core.common.extensions.debouncedClickable import com.into.websoso.core.designsystem.theme.Gray300 import com.into.websoso.core.designsystem.theme.Gray50 import com.into.websoso.core.designsystem.theme.Primary100 @@ -47,7 +47,7 @@ internal fun LibraryFilterBottomSheetNovelRatingGrid( title = "3.5 이상", modifier = Modifier .weight(weight = 1f) - .clickable { + .debouncedClickable { onRatingClick(THREE_POINT_FIVE) }, isSelected = selectedRating.isCloseTo(THREE_POINT_FIVE), @@ -56,7 +56,7 @@ internal fun LibraryFilterBottomSheetNovelRatingGrid( title = "4.0 이상", modifier = Modifier .weight(weight = 1f) - .clickable { + .debouncedClickable { onRatingClick(FOUR) }, isSelected = selectedRating.isCloseTo(FOUR), @@ -71,7 +71,7 @@ internal fun LibraryFilterBottomSheetNovelRatingGrid( title = "4.5 이상", modifier = Modifier .weight(weight = 1f) - .clickable { + .debouncedClickable { onRatingClick(FOUR_POINT_FIVE) }, isSelected = selectedRating.isCloseTo(FOUR_POINT_FIVE), @@ -80,7 +80,7 @@ internal fun LibraryFilterBottomSheetNovelRatingGrid( title = "4.8 이상", modifier = Modifier .weight(weight = 1f) - .clickable { + .debouncedClickable { onRatingClick(FOUR_POINT_EIGHT) }, isSelected = selectedRating.isCloseTo(FOUR_POINT_EIGHT), @@ -102,7 +102,8 @@ private fun NovelRatingItem( .background( color = backgroundColor, shape = RoundedCornerShape(size = 8.dp), - ).then( + ) + .then( if (isSelected) { Modifier.border( width = 1.dp, @@ -112,7 +113,8 @@ private fun NovelRatingItem( } else { Modifier }, - ).padding(vertical = 14.dp, horizontal = 24.dp), + ) + .padding(vertical = 14.dp, horizontal = 24.dp), contentAlignment = Alignment.Center, ) { Text( diff --git a/feature/library/src/main/java/com/into/websoso/feature/library/model/LibraryUiState.kt b/feature/library/src/main/java/com/into/websoso/feature/library/model/LibraryUiState.kt index 627e78712..b105fa174 100644 --- a/feature/library/src/main/java/com/into/websoso/feature/library/model/LibraryUiState.kt +++ b/feature/library/src/main/java/com/into/websoso/feature/library/model/LibraryUiState.kt @@ -2,6 +2,7 @@ package com.into.websoso.feature.library.model data class LibraryUiState( val isGrid: Boolean = true, + val novelTotalCount :Long= 0, val libraryFilterUiModel: LibraryFilterUiModel = LibraryFilterUiModel(), ) diff --git a/feature/library/src/main/java/com/into/websoso/feature/library/model/ReadStatusUiModel.kt b/feature/library/src/main/java/com/into/websoso/feature/library/model/ReadStatusUiModel.kt index ef61644f4..73749cee1 100644 --- a/feature/library/src/main/java/com/into/websoso/feature/library/model/ReadStatusUiModel.kt +++ b/feature/library/src/main/java/com/into/websoso/feature/library/model/ReadStatusUiModel.kt @@ -16,6 +16,7 @@ enum class ReadStatusUiModel( ; companion object { - fun from(readStatus: ReadStatus?): ReadStatusUiModel? = entries.find { it.readStatus == readStatus } + fun from(readStatus: ReadStatus?): ReadStatusUiModel? = + entries.find { it.readStatus == readStatus } } } diff --git a/feature/signin/src/main/java/com/into/websoso/feature/signin/component/SignInButtons.kt b/feature/signin/src/main/java/com/into/websoso/feature/signin/component/SignInButtons.kt index 7e6ebcc89..52e75922e 100644 --- a/feature/signin/src/main/java/com/into/websoso/feature/signin/component/SignInButtons.kt +++ b/feature/signin/src/main/java/com/into/websoso/feature/signin/component/SignInButtons.kt @@ -1,7 +1,6 @@ package com.into.websoso.feature.signin.component import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -11,6 +10,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import com.into.websoso.core.auth.AuthPlatform +import com.into.websoso.core.common.extensions.debouncedClickable import com.into.websoso.core.designsystem.theme.WebsosoTheme import com.into.websoso.core.resource.R.drawable.ic_login_kakao @@ -35,7 +35,7 @@ private fun KakaoSignInButton( Image( imageVector = ImageVector.vectorResource(id = ic_login_kakao), contentDescription = null, - modifier = modifier.clickable { + modifier = modifier.debouncedClickable { onClick(AuthPlatform.KAKAO) }, ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 755a51a76..1be43263d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,7 +31,6 @@ fragment-ktx = "1.8.5" lifecycle-extensions = "2.2.0" datastore-preferences = "1.2.0" security-crypto = "1.1.0" -room = "2.8.4" paging = "3.3.6" # Testing Libraries @@ -100,10 +99,6 @@ fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragm lifecycle-extensions = { module = "androidx.lifecycle:lifecycle-extensions", version.ref = "lifecycle-extensions" } datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore-preferences" } security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" } -room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } -room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } -room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } -room-paging = { module = "androidx.room:room-paging", version.ref = "room" } paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" } paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" } diff --git a/settings.gradle.kts b/settings.gradle.kts index b039f4186..e23a00fed 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -28,7 +28,6 @@ include( ":core:auth-kakao", ":core:network", ":core:datastore", - ":core:database", ) include(