diff --git a/app/src/main/java/com/into/websoso/data/mapper/FeedMapper.kt b/app/src/main/java/com/into/websoso/data/mapper/FeedMapper.kt index 509272e3f..dd8060626 100644 --- a/app/src/main/java/com/into/websoso/data/mapper/FeedMapper.kt +++ b/app/src/main/java/com/into/websoso/data/mapper/FeedMapper.kt @@ -111,10 +111,14 @@ fun PopularFeedsResponseDto.toData(): PopularFeedsEntity = popularFeeds = popularFeeds.map { feed -> PopularFeedsEntity.PopularFeedEntity( feedId = feed.feedId, - feesContent = feed.feedContent, + feesContent = feed.feedContent.orEmpty(), likeCount = feed.likeCount, commentCount = feed.commentCount, isSpoiler = feed.isSpoiler, + isPublic = feed.isPublic ?: false, + novelTitle = feed.novelTitle ?: feed.title.orEmpty(), + novelImage = feed.novelImage ?: feed.novelThumbnailImage.orEmpty(), + novelGenreImage = feed.novelGenreImage.orEmpty(), ) }, ) diff --git a/app/src/main/java/com/into/websoso/data/model/PopularFeedsEntity.kt b/app/src/main/java/com/into/websoso/data/model/PopularFeedsEntity.kt index 7399e789f..e6be43c24 100644 --- a/app/src/main/java/com/into/websoso/data/model/PopularFeedsEntity.kt +++ b/app/src/main/java/com/into/websoso/data/model/PopularFeedsEntity.kt @@ -9,5 +9,9 @@ data class PopularFeedsEntity( val likeCount: Int, val commentCount: Int, val isSpoiler: Boolean, + val isPublic: Boolean, + val novelTitle: String, + val novelImage: String, + val novelGenreImage: String, ) } diff --git a/app/src/main/java/com/into/websoso/data/remote/response/PopularFeedsResponseDto.kt b/app/src/main/java/com/into/websoso/data/remote/response/PopularFeedsResponseDto.kt index 3a7bdb72e..021300230 100644 --- a/app/src/main/java/com/into/websoso/data/remote/response/PopularFeedsResponseDto.kt +++ b/app/src/main/java/com/into/websoso/data/remote/response/PopularFeedsResponseDto.kt @@ -13,12 +13,24 @@ data class PopularFeedsResponseDto( @SerialName("feedId") val feedId: Long, @SerialName("feedContent") - val feedContent: String, + val feedContent: String? = null, @SerialName("likeCount") val likeCount: Int, @SerialName("commentCount") val commentCount: Int, @SerialName("isSpoiler") val isSpoiler: Boolean, + @SerialName("isPublic") + val isPublic: Boolean? = null, + @SerialName("title") + val title: String? = null, + @SerialName("novelTitle") + val novelTitle: String? = null, + @SerialName("novelImage") + val novelImage: String? = null, + @SerialName("novelThumbnailImage") + val novelThumbnailImage: String? = null, + @SerialName("novelGenreImage") + val novelGenreImage: String? = null, ) } 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 fefadad96..a871cf196 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 @@ -5,6 +5,9 @@ import com.into.websoso.data.model.FeedEntity import com.into.websoso.data.model.FeedsEntity import com.into.websoso.data.model.PopularFeedsEntity import com.into.websoso.data.remote.api.FeedApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import javax.inject.Inject import javax.inject.Singleton @@ -35,6 +38,34 @@ class FeedRepository suspend fun fetchPopularFeeds(): PopularFeedsEntity = feedApi.getPopularFeeds().toData() + suspend fun fetchPopularFeedsWithDetails(): List = + coroutineScope { + fetchPopularFeeds() + .popularFeeds + .map { feed -> + async { + runCatching { + val feedDetail = feedApi.getFeed(feed.feedId).toData() + val novel = feedDetail.novel ?: return@runCatching null + + feed.copy( + feesContent = feedDetail.content, + isSpoiler = feedDetail.isSpoiler, + isPublic = feedDetail.isPublic, + novelTitle = novel.title, + novelImage = feed.novelImage.ifBlank { novel.thumbnail }, + ) + }.getOrNull() + } + }.awaitAll() + .filterNotNull() + .filter { feed -> + feed.isPublic && + feed.novelTitle.isNotBlank() && + feed.novelImage.isNotBlank() + } + } + suspend fun saveRemovedFeed(feedId: Long) { runCatching { feedApi.deleteFeed(feedId) diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt index c17f6ffe0..619050877 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.into.websoso.data.model.PopularFeedsEntity import com.into.websoso.data.model.PopularNovelsEntity import com.into.websoso.data.model.RecommendedNovelsByUserTasteEntity import com.into.websoso.data.model.TermsAgreementEntity @@ -18,6 +17,7 @@ import com.into.websoso.ui.main.home.model.HomeUiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -69,42 +69,38 @@ class HomeViewModel } } - private fun fetchUserHomeData() { - viewModelScope.launch { - runCatching { - val results = listOf( - async { runCatching { novelRepository.fetchPopularNovels() } }, - async { runCatching { feedRepository.fetchPopularFeeds() } }, - async { runCatching { novelRepository.fetchRecommendedNovelsByUserTaste() } }, - ).awaitAll() - - val failures = results.filter { it.isFailure } - if (failures.isNotEmpty()) { - throw failures.first().exceptionOrNull() - ?: IllegalStateException("Unknown error") - } + private suspend fun fetchUserHomeData() { + coroutineScope { + val popularNovelsDeferred = + async { runCatching { novelRepository.fetchPopularNovels() } } + val popularFeedsDeferred = + async { runCatching { feedRepository.fetchPopularFeedsWithDetails() } } + val recommendedNovelsDeferred = + async { runCatching { novelRepository.fetchRecommendedNovelsByUserTaste() } } - val popularNovels = results[0].getOrNull() as? PopularNovelsEntity - ?: PopularNovelsEntity(emptyList()) - val popularFeeds = results[1].getOrNull() as? PopularFeedsEntity - ?: PopularFeedsEntity(emptyList()) - val recommendedNovels = - results[2].getOrNull() as? RecommendedNovelsByUserTasteEntity - ?: RecommendedNovelsByUserTasteEntity(emptyList()) + val popularNovelsResult = popularNovelsDeferred.await() + val popularFeedsResult = popularFeedsDeferred.await() + val recommendedNovelsResult = recommendedNovelsDeferred.await() - _uiState.value = uiState.value?.copy( - loading = false, - error = false, - popularNovels = popularNovels.popularNovels, - popularFeeds = popularFeeds.popularFeeds.chunked(3), - recommendedNovelsByUserTaste = recommendedNovels.tasteNovels, - ) - }.onFailure { - _uiState.value = uiState.value?.copy( - loading = false, - error = true, - ) + val failure = popularNovelsResult.exceptionOrNull() + ?: popularFeedsResult.exceptionOrNull() + ?: recommendedNovelsResult.exceptionOrNull() + if (failure != null) { + handleFailureState() + return@coroutineScope } + + val popularNovels = popularNovelsResult.getOrThrow() + val popularFeeds = popularFeedsResult.getOrThrow() + val recommendedNovels = recommendedNovelsResult.getOrThrow() + + _uiState.value = uiState.value?.copy( + loading = false, + error = false, + popularNovels = popularNovels.popularNovels, + popularFeeds = popularFeeds.chunked(HOME_POPULAR_FEED_PAGE_SIZE), + recommendedNovelsByUserTaste = recommendedNovels.tasteNovels, + ) } } @@ -129,37 +125,49 @@ class HomeViewModel } private suspend fun fetchGuestData() { - viewModelScope.launch { - runCatching { - listOf( - async { novelRepository.fetchPopularNovels() }, - async { feedRepository.fetchPopularFeeds() }, - ).awaitAll() - }.onSuccess { responses -> - val popularNovels = responses[0] as PopularNovelsEntity - val popularFeeds = responses[1] as PopularFeedsEntity + coroutineScope { + val popularNovelsDeferred = + async { runCatching { novelRepository.fetchPopularNovels() } } + val popularFeedsDeferred = + async { runCatching { feedRepository.fetchPopularFeedsWithDetails() } } - _uiState.value = uiState.value?.copy( - loading = false, - popularNovels = popularNovels.popularNovels, - popularFeeds = popularFeeds.popularFeeds.chunked(3), - ) - }.onFailure { - _uiState.value = uiState.value?.copy( - loading = false, - error = true, - ) + val popularNovelsResult = popularNovelsDeferred.await() + val popularFeedsResult = popularFeedsDeferred.await() + + val failure = popularNovelsResult.exceptionOrNull() + ?: popularFeedsResult.exceptionOrNull() + if (failure != null) { + handleFailureState() + return@coroutineScope } + + val popularNovels = popularNovelsResult.getOrThrow() + val popularFeeds = popularFeedsResult.getOrThrow() + + _uiState.value = uiState.value?.copy( + loading = false, + error = false, + popularNovels = popularNovels.popularNovels, + popularFeeds = popularFeeds.chunked(HOME_POPULAR_FEED_PAGE_SIZE), + ) } } + private fun handleFailureState() { + _uiState.value = uiState.value?.copy( + loading = false, + error = true, + ) + } + fun updateFeed() { viewModelScope.launch { runCatching { - feedRepository.fetchPopularFeeds() + feedRepository.fetchPopularFeedsWithDetails() }.onSuccess { popularFeeds -> _uiState.value = uiState.value?.copy( - popularFeeds = popularFeeds.popularFeeds.chunked(3), + error = false, + popularFeeds = popularFeeds.chunked(HOME_POPULAR_FEED_PAGE_SIZE), ) }.onFailure { _uiState.value = uiState.value?.copy(error = true) @@ -251,4 +259,8 @@ class HomeViewModel } } } + + companion object { + private const val HOME_POPULAR_FEED_PAGE_SIZE = 2 + } } diff --git a/app/src/main/java/com/into/websoso/ui/main/home/adpater/PopularFeedsAdapter.kt b/app/src/main/java/com/into/websoso/ui/main/home/adpater/PopularFeedsAdapter.kt index 73800c8f2..1ccad23d1 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/adpater/PopularFeedsAdapter.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/adpater/PopularFeedsAdapter.kt @@ -32,7 +32,7 @@ class PopularFeedsAdapter( override fun areItemsTheSame( oldItem: List, newItem: List, - ): Boolean = oldItem.firstOrNull()?.feedId == newItem.firstOrNull()?.feedId + ): Boolean = oldItem.map { it.feedId } == newItem.map { it.feedId } @SuppressLint("DiffUtilEquals") override fun areContentsTheSame( diff --git a/app/src/main/java/com/into/websoso/ui/main/home/adpater/PopularFeedsViewHolder.kt b/app/src/main/java/com/into/websoso/ui/main/home/adpater/PopularFeedsViewHolder.kt index d4cd81b3d..31575539a 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/adpater/PopularFeedsViewHolder.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/adpater/PopularFeedsViewHolder.kt @@ -1,9 +1,17 @@ package com.into.websoso.ui.main.home.adpater +import android.util.Patterns import android.view.View import androidx.recyclerview.widget.RecyclerView +import coil.decode.SvgDecoder +import coil.load +import coil.transform.RoundedCornersTransformation +import com.into.websoso.core.common.util.getS3ImageUrl +import com.into.websoso.core.common.util.toFloatPxFromDp +import com.into.websoso.core.resource.R.drawable.img_loading_thumbnail import com.into.websoso.data.model.PopularFeedsEntity.PopularFeedEntity import com.into.websoso.databinding.ItemPopularFeedBinding +import com.into.websoso.databinding.ItemPopularFeedSlotBinding class PopularFeedsViewHolder( private val binding: ItemPopularFeedBinding, @@ -13,24 +21,63 @@ class PopularFeedsViewHolder( listOf( binding.itemPopularFeesSlot1, binding.itemPopularFeesSlot2, - binding.itemPopularFeesSlot3, ) } fun bind(feedItems: List) { + binding.viewPopularFeedDivider1.visibility = + if (feedItems.size > 1) View.VISIBLE else View.GONE + slots.forEachIndexed { index, slotBinding -> - slotBinding.apply { - feedItems.getOrNull(index)?.let { feed -> - tvPopularFeedContent.text = feed.feesContent - tvPopularFeedLikeCount.text = feed.likeCount.toString() - tvPopularFeedCommentCount.text = feed.commentCount.toString() - tvPopularFeedContent.visibility = - if (feed.isSpoiler) View.INVISIBLE else View.VISIBLE - tvPopularFeedContentSpoiler.visibility = - if (feed.isSpoiler) View.VISIBLE else View.GONE - root.setOnClickListener { onFeedClick(feed.feedId) } - } + val feed = feedItems.getOrNull(index) + if (feed == null) { + slotBinding.root.visibility = View.INVISIBLE + slotBinding.root.setOnClickListener(null) + return@forEachIndexed + } + + slotBinding.root.visibility = View.VISIBLE + slotBinding.bind(feed) + } + } + + private fun ItemPopularFeedSlotBinding.bind(feed: PopularFeedEntity) { + tvPopularFeedTitle.text = feed.novelTitle.ellipsizeByLength() + tvPopularFeedContent.text = if (feed.isSpoiler) "" else feed.feesContent + tvPopularFeedContent.visibility = if (feed.isSpoiler) View.GONE else View.VISIBLE + tvPopularFeedContentSpoiler.visibility = if (feed.isSpoiler) View.VISIBLE else View.GONE + ivPopularFeedThumbnail.load(feed.novelImage.toImageUrl()) { + crossfade(true) + transformations(RoundedCornersTransformation(8f.toFloatPxFromDp())) + error(img_loading_thumbnail) + } + val isGenreVisible = feed.novelGenreImage.isNotBlank() + ivPopularFeedGenreFrame.visibility = if (isGenreVisible) View.VISIBLE else View.GONE + ivPopularFeedGenre.visibility = if (isGenreVisible) View.VISIBLE else View.GONE + if (isGenreVisible) { + ivPopularFeedGenre.load(feed.novelGenreImage.toImageUrl()) { + crossfade(true) + decoderFactory(SvgDecoder.Factory()) } } + root.setOnClickListener { onFeedClick(feed.feedId) } + } + + private fun String.toImageUrl(): String = + when { + isBlank() -> "" + Patterns.WEB_URL.matcher(this).matches() -> this + else -> itemView.getS3ImageUrl(this) + } + + private fun String.ellipsizeByLength(): String = + if (length > MAX_TITLE_LENGTH) { + take(MAX_TITLE_LENGTH) + "..." + } else { + this + } + + companion object { + private const val MAX_TITLE_LENGTH = 16 } } diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 8ffff268d..1fc981807 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -191,7 +191,7 @@ + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> - - - - + app:layout_constraintTop_toBottomOf="@id/view_popular_feed_divider_1" /> diff --git a/app/src/main/res/layout/item_popular_feed_slot.xml b/app/src/main/res/layout/item_popular_feed_slot.xml index 4ab801b4b..8edfdd036 100644 --- a/app/src/main/res/layout/item_popular_feed_slot.xml +++ b/app/src/main/res/layout/item_popular_feed_slot.xml @@ -9,85 +9,86 @@ + android:layout_height="match_parent" + android:paddingHorizontal="20dp" + android:paddingVertical="16dp"> + app:layout_constraintHorizontal_bias="0" + tools:text="피폐 역하렘 남주들의 막내 처제가..." /> + + + app:layout_constraintEnd_toEndOf="@id/tv_popular_feed_title" + app:layout_constraintStart_toStartOf="@id/tv_popular_feed_title" + app:layout_constraintTop_toBottomOf="@id/tv_popular_feed_title" + tools:visibility="visible" /> - + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toEndOf="@id/tv_popular_feed_title" + app:layout_constraintTop_toTopOf="parent"> - + - + - + +