fix(home): 팔로잉 탭 후속 검증을 반영한다

This commit is contained in:
2026-07-31 03:22:50 +09:00
parent f600d10e8b
commit 49f6fe592c
37 changed files with 2918 additions and 447 deletions

View File

@@ -20,6 +20,7 @@ import kr.co.vividnext.sodalive.v2.access.AccessRequirement
import kr.co.vividnext.sodalive.v2.access.ensureV2Access
import kr.co.vividnext.sodalive.v2.chat.action.ChatActionCommand
import kr.co.vividnext.sodalive.v2.chat.action.handleChatAction
import kr.co.vividnext.sodalive.v2.common.CreatorActivityType
import kr.co.vividnext.sodalive.v2.community.action.CommunityActionCommand
import kr.co.vividnext.sodalive.v2.community.action.handleCommunityAction
import kr.co.vividnext.sodalive.v2.content.action.ContentActionCommand
@@ -30,6 +31,7 @@ import kr.co.vividnext.sodalive.v2.live.action.LiveActionCoordinator
import kr.co.vividnext.sodalive.v2.live.onair.HomeOnAirLiveActivity
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomListUiItem
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomType
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingNewsType
import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingChatSection
import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingCreatorSection
import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingLiveSection
@@ -409,7 +411,10 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
bindHomeFollowingEmpty(showEmptyMessage = true)
finishHomePullRefresh(HOME_TAB_FOLLOWING)
}
HomeFollowingUiState.LoginRequired -> bindHomeFollowingEmpty(showEmptyMessage = false)
HomeFollowingUiState.LoginRequired -> {
bindHomeFollowingEmpty(showEmptyMessage = false)
finishHomePullRefresh(HOME_TAB_FOLLOWING)
}
is HomeFollowingUiState.Error -> {
if (shouldPreserveHomeContentOnPullRefreshError(HOME_TAB_FOLLOWING)) return@observe
bindHomeFollowingEmpty(showEmptyMessage = false)
@@ -665,15 +670,38 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
private fun onFollowingSectionMoreClick(section: HomeFollowingSection) = Unit
private fun onFollowingLiveClick(item: HomeFollowingLiveUiItem) = Unit
private fun onFollowingLiveClick(item: HomeFollowingLiveUiItem) {
liveActionCoordinator.enterLiveRoom(item.liveId)
}
private fun onFollowingScheduleClick(item: HomeFollowingScheduleUiItem) = Unit
private fun onFollowingScheduleClick(item: HomeFollowingScheduleUiItem) {
if (item.targetId <= 0L) return
when (item.type) {
CreatorActivityType.Live -> liveActionCoordinator.enterLiveRoom(item.targetId)
CreatorActivityType.LiveReplay,
CreatorActivityType.Audio -> handleContentAction(
ContentActionCommand.AudioDetail(audioContentId = item.targetId)
)
CreatorActivityType.Community -> handleCommunityAction(
CommunityActionCommand.PostDetail(item.targetId)
)
}
}
private fun onFollowingNewsClick(item: HomeFollowingNewsUiItem) {
val community = item as? HomeFollowingNewsUiItem.Community ?: return
val postId = community.postId
if (postId <= 0L) return
handleCommunityAction(CommunityActionCommand.PostDetail(postId))
when (item) {
is HomeFollowingNewsUiItem.Ranking -> if (item.type == FollowingNewsType.CREATOR_RANKING) {
handleCreatorAction(CreatorActionCommand.Profile(item.targetId))
}
is HomeFollowingNewsUiItem.Content -> if (item.type == FollowingNewsType.AUDIO_CONTENT) {
handleContentAction(ContentActionCommand.AudioDetail(audioContentId = item.contentId))
}
is HomeFollowingNewsUiItem.Community -> {
val postId = item.postId
if (postId <= 0L) return
handleCommunityAction(CommunityActionCommand.PostDetail(postId))
}
}
}
private fun openFollowingCreatorAll() {

View File

@@ -1,7 +1,5 @@
package kr.co.vividnext.sodalive.v2.main.home.model
import androidx.annotation.StringRes
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.v2.main.chat.model.toUiItems
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingCommunityPostNewsResponse
@@ -123,8 +121,7 @@ private fun FollowingContentNewsResponse.toUiItem(
contentImageUrl = contentImageUrl,
title = title,
creatorProfileImageUrl = creatorProfileImageUrl,
creatorNickname = creatorNickname,
labelResId = type.toLabelResId()
creatorNickname = creatorNickname
)
private fun FollowingCommunityPostNewsResponse.toUiItem(
@@ -147,12 +144,3 @@ private fun FollowingCommunityPostNewsResponse.toUiItem(
likeCount = likeCount,
commentCount = commentCount
)
@StringRes
private fun FollowingNewsType.toLabelResId(): Int = when (this) {
FollowingNewsType.PHOTO_CONTENT -> R.string.screen_home_following_photo_content
FollowingNewsType.AUDIO_CONTENT -> R.string.home_recommendation_activity_audio
FollowingNewsType.COMMUNITY_POST -> R.string.home_recommendation_activity_community
FollowingNewsType.CREATOR_RANKING,
FollowingNewsType.CONTENT_RANKING -> R.string.weekly_chart
}

View File

@@ -81,8 +81,7 @@ sealed interface HomeFollowingNewsUiItem {
val contentImageUrl: String?,
val title: String,
val creatorProfileImageUrl: String,
val creatorNickname: String,
@param:StringRes val labelResId: Int
val creatorNickname: String
) : HomeFollowingNewsUiItem
data class Community(

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.v2.main.home.ui
import android.widget.ImageView
import coil.dispose
import coil.transform.CircleCropTransformation
import coil.transform.Transformation
import kr.co.vividnext.sodalive.extensions.loadUrl
@@ -9,6 +10,7 @@ fun homeCreatorProfileImageTransformations(): List<Transformation> = listOf(Circ
fun ImageView.loadHomeCreatorProfileImage(url: String?) {
if (url.isNullOrBlank()) {
dispose()
setImageDrawable(null)
} else {
loadUrl(url) {

View File

@@ -22,7 +22,9 @@ class HomeFollowingChatAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChatViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_home_following_chat, parent, false)
view.layoutParams = recyclerItemLayoutParams(parent)
val layoutParams = view.layoutParams as RecyclerView.LayoutParams
layoutParams.marginEnd = parent.resources.getDimensionPixelSize(R.dimen.spacing_12)
view.layoutParams = layoutParams
return ChatViewHolder(view, onClickItem)
}

View File

@@ -21,14 +21,15 @@ class HomeFollowingLiveAdapter(
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LiveViewHolder {
return LiveViewHolder(
LayoutInflater.from(parent.context).inflate(
R.layout.item_home_following_live,
parent,
false
),
onClickItem
val view = LayoutInflater.from(parent.context).inflate(
R.layout.item_home_following_live,
parent,
false
)
val layoutParams = view.layoutParams as RecyclerView.LayoutParams
layoutParams.marginEnd = parent.resources.getDimensionPixelSize(R.dimen.spacing_12)
view.layoutParams = layoutParams
return LiveViewHolder(view, onClickItem)
}
override fun onBindViewHolder(holder: LiveViewHolder, position: Int) {

View File

@@ -1,9 +1,11 @@
package kr.co.vividnext.sodalive.v2.main.home.ui
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import coil.dispose
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingNewsType
@@ -19,6 +21,38 @@ import kr.co.vividnext.sodalive.v2.widget.feed.FeedWidthMode
import kr.co.vividnext.sodalive.v2.widget.feed.FeedCommunityView
import kotlin.math.roundToInt
internal fun buildHomeFollowingRankingFeedItem(
context: Context,
ranking: HomeFollowingNewsUiItem.Ranking
): FeedItem.Rank {
val rankText = context.getString(R.string.screen_home_following_ranking_rank_format, ranking.rank)
val message = ranking.toRankingMessage(context, rankText)
return FeedItem.Rank(
feedId = ranking.newsId,
imageUrl = ranking.imageUrl.orEmpty(),
rankText = ranking.rank.toString(),
message = message,
highlightRanges = listOf(
FeedRankHighlight(
start = message.lastIndexOf(rankText),
endExclusive = message.lastIndexOf(rankText) + rankText.length
)
)
)
}
private fun HomeFollowingNewsUiItem.Ranking.toRankingMessage(context: Context, rankText: String): String {
val messageResId = when (type) {
FollowingNewsType.CREATOR_RANKING -> R.string.screen_home_following_creator_ranking_news_message
FollowingNewsType.CONTENT_RANKING -> R.string.screen_home_following_content_ranking_news_message
FollowingNewsType.COMMUNITY_POST,
FollowingNewsType.AUDIO_CONTENT,
FollowingNewsType.PHOTO_CONTENT -> R.string.screen_home_following_content_ranking_news_message
}
val subject = if (type == FollowingNewsType.CREATOR_RANKING) creatorNickname else title
return context.getString(messageResId, subject, rankText)
}
class HomeFollowingNewsAdapter(
private val onClickItem: (HomeFollowingNewsUiItem) -> Unit = {}
) : RecyclerView.Adapter<HomeFollowingNewsAdapter.NewsViewHolder>() {
@@ -73,39 +107,12 @@ class HomeFollowingNewsAdapter(
override fun bind(item: HomeFollowingNewsUiItem) {
val ranking = item as HomeFollowingNewsUiItem.Ranking
val context = view.context
val rankText = "${ranking.rank}"
val message = ranking.toRankingMessage(rankText)
val feedItem = FeedItem.Rank(
feedId = ranking.newsId,
imageUrl = ranking.imageUrl.orEmpty(),
rankText = ranking.rank.toString(),
message = message,
highlightRanges = listOf(
FeedRankHighlight(
start = message.indexOf(rankText),
endExclusive = message.indexOf(rankText) + rankText.length
)
)
)
val feedItem = buildHomeFollowingRankingFeedItem(view.context, ranking)
view.setFeedSize(feedSize(FeedVariant.Rank, parent))
view.bind(feedItem)
bindImage(view.imageView(), feedItem.imageUrl)
view.setOnFeedClick { onClickItem(ranking) }
}
private fun HomeFollowingNewsUiItem.Ranking.toRankingMessage(rankText: String): String {
val messageResId = when (type) {
FollowingNewsType.CREATOR_RANKING -> R.string.screen_home_following_creator_ranking_news_message
FollowingNewsType.CONTENT_RANKING -> R.string.screen_home_following_content_ranking_news_message
FollowingNewsType.COMMUNITY_POST,
FollowingNewsType.AUDIO_CONTENT,
FollowingNewsType.PHOTO_CONTENT -> R.string.screen_home_following_content_ranking_news_message
}
val subject = if (type == FollowingNewsType.CREATOR_RANKING) creatorNickname else title
val context = view.context
return context.getString(messageResId, subject, rankText)
}
}
class CommunityViewHolder(
@@ -118,7 +125,7 @@ class HomeFollowingNewsAdapter(
val community = item as HomeFollowingNewsUiItem.Community
val feedItem = FeedItem.Community(
feedId = community.newsId,
creatorId = community.targetId.toString(),
creatorId = "",
creatorName = community.creatorNickname,
creatorImageUrl = community.creatorProfileImageUrl,
postId = community.postId.toString(),
@@ -149,7 +156,7 @@ class HomeFollowingNewsAdapter(
val content = item as HomeFollowingNewsUiItem.Content
val feedItem = FeedItem.Content(
feedId = content.newsId,
creatorId = content.targetId.toString(),
creatorId = "",
creatorName = content.creatorNickname,
creatorImageUrl = content.creatorProfileImageUrl,
contentId = content.contentId.toString(),
@@ -184,6 +191,7 @@ class HomeFollowingNewsAdapter(
onImageLoaded: ((Int, Int) -> Unit)? = null
) {
if (url.isBlank()) {
imageView.dispose()
imageView.setImageDrawable(null)
} else {
imageView.loadUrl(url) {

View File

@@ -1,126 +0,0 @@
package kr.co.vividnext.sodalive.v2.widget.feed
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kotlin.math.roundToInt
class FeedAdapter(
private val widthMode: FeedWidthMode = FeedWidthMode.FigmaFixed,
private val horizontalItemDecorationDp: Int = 0,
private val onClickItem: (FeedItem) -> Unit,
private val onBindImages: (FeedImageViews, FeedItem) -> Unit = { _, _ -> }
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val items = mutableListOf<FeedItem>()
override fun getItemViewType(position: Int): Int = when (items[position].variant) {
FeedVariant.Rank -> VIEW_TYPE_RANK
FeedVariant.Live -> VIEW_TYPE_LIVE
FeedVariant.Content -> VIEW_TYPE_CONTENT
FeedVariant.Community -> VIEW_TYPE_COMMUNITY
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
VIEW_TYPE_RANK -> RankViewHolder(inflater.inflate(R.layout.view_feed_rank, parent, false) as FeedRankView, parent)
VIEW_TYPE_LIVE -> LiveViewHolder(inflater.inflate(R.layout.view_feed_live, parent, false) as FeedLiveView, parent)
VIEW_TYPE_CONTENT -> ContentViewHolder(
inflater.inflate(R.layout.view_feed_content, parent, false) as FeedContentView,
parent
)
VIEW_TYPE_COMMUNITY -> CommunityViewHolder(
inflater.inflate(R.layout.view_feed_community, parent, false) as FeedCommunityView,
parent
)
else -> error("Unknown viewType: $viewType")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = items[position]) {
is FeedItem.Rank -> (holder as RankViewHolder).bind(item)
is FeedItem.Live -> (holder as LiveViewHolder).bind(item)
is FeedItem.Content -> (holder as ContentViewHolder).bind(item)
is FeedItem.Community -> (holder as CommunityViewHolder).bind(item)
}
}
override fun getItemCount(): Int = items.size
fun submitItems(items: List<FeedItem>) {
this.items.clear()
this.items.addAll(items)
notifyDataSetChanged()
}
private inner class RankViewHolder(
private val view: FeedRankView,
private val parent: ViewGroup
) : RecyclerView.ViewHolder(view) {
fun bind(item: FeedItem.Rank) {
view.setFeedSize(calculateSize(item.variant, parent))
view.bind(item)
view.setOnFeedClick(onClickItem)
onBindImages(FeedImageViews(primary = view.imageView()), item)
}
}
private inner class LiveViewHolder(
private val view: FeedLiveView,
private val parent: ViewGroup
) : RecyclerView.ViewHolder(view) {
fun bind(item: FeedItem.Live) {
view.setFeedSize(calculateSize(item.variant, parent))
view.bind(item)
view.setOnFeedClick(onClickItem)
onBindImages(FeedImageViews(profile = view.profileImageView()), item)
}
}
private inner class ContentViewHolder(
private val view: FeedContentView,
private val parent: ViewGroup
) : RecyclerView.ViewHolder(view) {
fun bind(item: FeedItem.Content) {
view.setFeedSize(calculateSize(item.variant, parent))
view.bind(item)
view.setOnFeedClick(onClickItem)
onBindImages(FeedImageViews(primary = view.contentImageView(), profile = view.profileImageView()), item)
}
}
private inner class CommunityViewHolder(
private val view: FeedCommunityView,
private val parent: ViewGroup
) : RecyclerView.ViewHolder(view) {
fun bind(item: FeedItem.Community) {
view.setFeedSize(calculateSize(item.variant, parent))
view.bind(item)
view.setOnFeedClick(onClickItem)
onBindImages(FeedImageViews(primary = view.communityImageView(), profile = view.profileImageView()), item)
}
}
private fun calculateSize(variant: FeedVariant, parent: ViewGroup): FeedSize {
val parentWidthPx = parent.width.takeIf { it > 0 } ?: parent.resources.displayMetrics.widthPixels
val availableWidthPx = parentWidthPx - parent.paddingLeft - parent.paddingRight
val availableWidthDp = (availableWidthPx / parent.resources.displayMetrics.density).roundToInt()
return FeedSize.from(variant, widthMode, availableWidthDp, horizontalItemDecorationDp)
}
companion object {
private const val VIEW_TYPE_RANK = 1
private const val VIEW_TYPE_LIVE = 2
private const val VIEW_TYPE_CONTENT = 3
private const val VIEW_TYPE_COMMUNITY = 4
}
}
data class FeedImageViews(
val primary: ImageView? = null,
val profile: ImageView? = null
)

View File

@@ -365,7 +365,7 @@
android:orientation="vertical"
android:paddingHorizontal="@dimen/spacing_14"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_home_following_news_content" />
tools:listitem="@layout/view_feed_content" />
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@@ -1,118 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_feed_card"
android:orientation="vertical"
android:padding="@dimen/spacing_14">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/iv_home_following_news_creator_profile"
android:layout_width="42dp"
android:layout_height="42dp"
android:background="@drawable/bg_round_corner_999_263238"
android:contentDescription="@null"
android:scaleType="centerCrop"
tools:src="@drawable/ic_placeholder_profile" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_8"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tv_home_following_news_creator_nickname"
style="@style/Typography.Body5"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
tools:text="크리에이터 이름" />
<TextView
android:id="@+id/tv_home_following_news_created_at"
style="@style/Typography.Body6"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/gray_500"
tools:text="2분 전" />
</LinearLayout>
</LinearLayout>
<TextView
android:id="@+id/tv_home_following_news_title"
style="@style/Typography.Heading4"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_14"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
tools:text="콘텐츠 이름" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_8"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_home_following_news_label"
style="@style/Typography.Caption3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_feed_category_tag"
android:includeFontPadding="false"
android:paddingHorizontal="@dimen/spacing_4"
android:paddingVertical="2dp"
android:textColor="@color/gray_100"
tools:text="오디오" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_12"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_home_following_news_content"
style="@style/Typography.Body3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="4"
android:textColor="@color/white"
tools:text="크리에이터가 남긴 최근 소식이 노출됩니다. 긴 내용은 말줄임 처리합니다." />
<ImageView
android:id="@+id/iv_home_following_news_thumbnail"
android:layout_width="92dp"
android:layout_height="92dp"
android:layout_marginStart="@dimen/spacing_8"
android:background="@drawable/bg_feed_community_image"
android:contentDescription="@null"
android:scaleType="centerCrop"
tools:src="@drawable/ic_launcher_background" />
</LinearLayout>
</LinearLayout>

View File

@@ -1,42 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingVertical="@dimen/spacing_8">
<TextView
android:id="@+id/tv_home_following_news_rank"
style="@style/Typography.Heading3"
android:layout_width="32dp"
android:layout_height="wrap_content"
android:gravity="center"
android:includeFontPadding="false"
android:textColor="@color/green_400"
tools:text="1" />
<TextView
android:id="@+id/tv_home_following_news_rank_title"
style="@style/Typography.Body4"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_8"
android:layout_weight="1"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
tools:text="최근 소식 제목" />
<TextView
android:id="@+id/tv_home_following_news_rank_count"
style="@style/Typography.Body6"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_8"
android:includeFontPadding="false"
android:textColor="@color/gray_500"
tools:text="999" />
</LinearLayout>

View File

@@ -309,17 +309,15 @@
<string name="home_recommendation_section_genre_creator_suffix">\u0020creators</string>
<string name="home_recommendation_section_cheer_creators">Creators with recent cheers</string>
<string name="home_recommendation_section_popular_community_posts">Popular community</string>
<string name="screen_home_following_creators_title">Following creators</string>
<string name="screen_home_following_on_air_title">On Air</string>
<string name="screen_home_following_recent_chats_title">Recent chats</string>
<string name="screen_home_following_monthly_schedules_title">Monthly schedule</string>
<string name="screen_home_following_recent_news_title">Recent news</string>
<string name="screen_home_following_on_air">On Air</string>
<string name="screen_home_following_photo_content">Photo</string>
<string name="screen_home_following_ranking_rank_format">No. %1$d</string>
<string name="screen_home_following_creator_ranking_news_message">%1$s reached %2$s in this week\'s ranking!</string>
<string name="screen_home_following_content_ranking_news_message">%1$s reached %2$s in this week\'s ranking!</string>
<string name="screen_home_following_empty">No following updates yet.\nFollow creators you are interested in.</string>
<string name="screen_home_following_error">Could not load following news.</string>
<string name="home_recommendation_activity_live">Live</string>
<string name="home_recommendation_activity_audio">Audio</string>
<string name="home_recommendation_activity_community">Community</string>

View File

@@ -309,17 +309,15 @@
<string name="home_recommendation_section_genre_creator_suffix">のクリエイター</string>
<string name="home_recommendation_section_cheer_creators">最近応援が多いクリエイター</string>
<string name="home_recommendation_section_popular_community_posts">人気コミュニティ</string>
<string name="screen_home_following_creators_title">フォロー中のクリエイター</string>
<string name="screen_home_following_on_air_title">On Air</string>
<string name="screen_home_following_recent_chats_title">最近のチャット</string>
<string name="screen_home_following_monthly_schedules_title">今月のスケジュール</string>
<string name="screen_home_following_recent_news_title">最近のお知らせ</string>
<string name="screen_home_following_on_air">On Air</string>
<string name="screen_home_following_photo_content">グラビア</string>
<string name="screen_home_following_ranking_rank_format">%1$d位</string>
<string name="screen_home_following_creator_ranking_news_message">%1$sさんが今週のランキング%2$sに入りました</string>
<string name="screen_home_following_content_ranking_news_message">%1$sが今週のランキング%2$sに入りました</string>
<string name="screen_home_following_empty">フォロー中のお知らせはまだありません。\n気になるクリエイターをフォローしてみましょう。</string>
<string name="screen_home_following_error">フォロー中のお知らせを読み込めませんでした。</string>
<string name="home_recommendation_activity_live">ライブ</string>
<string name="home_recommendation_activity_audio">オーディオ</string>
<string name="home_recommendation_activity_community">コミュニティ</string>

View File

@@ -308,17 +308,15 @@
<string name="home_recommendation_section_genre_creator_suffix">\u0020크리에이터</string>
<string name="home_recommendation_section_cheer_creators">최근 응원이 많은 크리에이터</string>
<string name="home_recommendation_section_popular_community_posts">인기 커뮤니티</string>
<string name="screen_home_following_creators_title">팔로잉 크리에이터</string>
<string name="screen_home_following_on_air_title">On Air</string>
<string name="screen_home_following_recent_chats_title">최근 대화</string>
<string name="screen_home_following_monthly_schedules_title">이달의 스케줄</string>
<string name="screen_home_following_recent_news_title">최근 소식</string>
<string name="screen_home_following_on_air">On Air</string>
<string name="screen_home_following_photo_content">화보</string>
<string name="screen_home_following_ranking_rank_format">%1$d위</string>
<string name="screen_home_following_creator_ranking_news_message">%1$s님이 이번 주 랭킹 %2$s에 올랐어요!</string>
<string name="screen_home_following_content_ranking_news_message">%1$s 콘텐츠가 이번 주 랭킹 %2$s에 올랐어요!</string>
<string name="screen_home_following_empty">아직 팔로잉 소식이 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.</string>
<string name="screen_home_following_error">팔로잉 소식을 불러오지 못했습니다.</string>
<string name="home_recommendation_activity_live">라이브</string>
<string name="home_recommendation_activity_audio">오디오</string>
<string name="home_recommendation_activity_community">커뮤니티</string>

View File

@@ -0,0 +1,42 @@
package kr.co.vividnext.sodalive.v2.main.home
import android.app.Application
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.widget.ImageView
import coil.load
import kr.co.vividnext.sodalive.common.ImageLoaderProvider
import kr.co.vividnext.sodalive.v2.main.home.ui.loadHomeCreatorProfileImage
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
class HomeCreatorProfileImageLoaderTest {
@Before
fun setUp() {
if (!ImageLoaderProvider.isInitialized) {
ImageLoaderProvider.init(RuntimeEnvironment.getApplication())
}
}
@Test
fun `blank 프로필 이미지는 이전 Coil 요청을 해제하고 drawable을 비운다`() {
val imageView = ImageView(RuntimeEnvironment.getApplication())
val disposable = imageView.load(ColorDrawable(Color.RED), ImageLoaderProvider.imageLoader)
assertFalse(disposable.isDisposed)
imageView.loadHomeCreatorProfileImage(" ")
assertTrue(disposable.isDisposed)
assertNull(imageView.drawable)
}
}

View File

@@ -8,7 +8,7 @@ import java.io.File
class HomeFollowingFragmentSourceTest {
@Test
fun `home layout exposes following content and section recycler ids`() {
fun ` layout은 팔로잉 content section recycler id를 노출한다`() {
val layout = homeMainLayoutSource()
assertTrue(layout.contains("@+id/nsv_home_following_content"))
@@ -20,7 +20,7 @@ class HomeFollowingFragmentSourceTest {
}
@Test
fun `home main fragment injects following viewmodel and wires adapters`() {
fun ` main fragment는 팔로잉 viewmodel과 adapter를 연결한다`() {
val source = homeMainFragmentSource()
assertTrue(source.contains("HomeFollowingViewModel"))
@@ -38,7 +38,7 @@ class HomeFollowingFragmentSourceTest {
}
@Test
fun `following tab branch shows following content and hides other home surfaces`() {
fun `팔로잉 탭 분기는 팔로잉 content만 표시한다`() {
val source = homeMainFragmentSource()
val branch = source.substringAfter("private fun showHomeTab(index: Int)")
.substringAfter("HOME_TAB_FOLLOWING ->")
@@ -50,7 +50,7 @@ class HomeFollowingFragmentSourceTest {
}
@Test
fun `following tab loads following content only once`() {
fun `팔로잉 탭은 팔로잉 content를 최초 한 번만 로드한다`() {
val source = homeMainFragmentSource()
assertTrue(source.contains("private var hasLoadedFollowing = false"))
@@ -60,7 +60,7 @@ class HomeFollowingFragmentSourceTest {
}
@Test
fun `following section chevrons call callback without starting activity`() {
fun `팔로잉 section chevron은 이동 없이 callback만 호출한다`() {
val source = homeMainFragmentSource()
val callback = source.substringAfter("private fun onFollowingSectionMoreClick")
.substringBefore("\n private fun")
@@ -77,7 +77,7 @@ class HomeFollowingFragmentSourceTest {
}
@Test
fun `following state binding clears or binds each following section`() {
fun `팔로잉 상태 binding은 section을 비우거나 바인딩한다`() {
val source = homeMainFragmentSource()
assertTrue(source.contains("followingStateLiveData.observe(viewLifecycleOwner)"))
@@ -85,7 +85,10 @@ class HomeFollowingFragmentSourceTest {
assertTrue(source.contains("bindHomeFollowingContent(state)"))
assertTrue(source.contains("HomeFollowingUiState.Empty ->"))
assertTrue(source.contains("bindHomeFollowingEmpty(showEmptyMessage = true)"))
assertTrue(source.contains("HomeFollowingUiState.LoginRequired -> bindHomeFollowingEmpty(showEmptyMessage = false)"))
val loginRequiredSource = source.substringAfter("HomeFollowingUiState.LoginRequired ->")
.substringBefore("is HomeFollowingUiState.Error ->")
assertTrue(loginRequiredSource.contains("bindHomeFollowingEmpty(showEmptyMessage = false)"))
assertTrue(loginRequiredSource.contains("finishHomePullRefresh(HOME_TAB_FOLLOWING)"))
assertTrue(source.contains("is HomeFollowingUiState.Error ->"))
assertTrue(source.contains("if (shouldPreserveHomeContentOnPullRefreshError(HOME_TAB_FOLLOWING)) return@observe"))
assertTrue(source.contains("bindHomeFollowingEmpty(showEmptyMessage = false)"))
@@ -102,7 +105,7 @@ class HomeFollowingFragmentSourceTest {
}
@Test
fun `following empty state shows centered reusable empty string only for all empty content`() {
fun `팔로잉 empty 상태는 다국어 빈 문구와 상태 분기를 유지한다`() {
val layout = homeMainLayoutSource()
val emptyText = layout.substringAfter("@+id/tv_home_following_empty")
.substringBefore("@+id/ll_home_following_creators_section")
@@ -112,12 +115,7 @@ class HomeFollowingFragmentSourceTest {
val stringsJa = projectFile("app/src/main/res/values-ja/strings.xml").readText()
assertTrue(layout.contains("@+id/tv_home_following_empty"))
assertTrue(emptyText.contains("android:layout_width=\"match_parent\""))
assertTrue(emptyText.contains("android:layout_height=\"match_parent\""))
assertTrue(emptyText.contains("android:gravity=\"center\""))
assertTrue(emptyText.contains("android:textAlignment=\"center\""))
assertTrue(emptyText.contains("android:text=\"@string/screen_home_following_empty\""))
assertTrue(emptyText.contains("android:visibility=\"gone\""))
assertTrue(
strings.contains(
@@ -138,8 +136,6 @@ class HomeFollowingFragmentSourceTest {
)
)
assertTrue(source.contains("binding.tvHomeFollowingEmpty.visibility = View.GONE"))
assertTrue(source.contains("binding.tvHomeFollowingEmpty.visibility = if (showEmptyMessage) View.VISIBLE else View.GONE"))
assertTrue(source.contains("HomeFollowingUiState.Empty ->"))
assertTrue(source.contains("bindHomeFollowingEmpty(showEmptyMessage = true)"))
assertTrue(source.contains("is HomeFollowingUiState.Error ->"))
@@ -147,7 +143,7 @@ class HomeFollowingFragmentSourceTest {
}
@Test
fun `following adapters bind figma required item fields`() {
fun `팔로잉 adapter는 필수 item field를 바인딩한다`() {
val liveAdapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeFollowingLiveAdapter.kt"
).readText()
@@ -183,7 +179,7 @@ class HomeFollowingFragmentSourceTest {
}
@Test
fun `following recent news hides chevron and reuses feed widgets by item type`() {
fun `팔로잉 최근 소식은 chevron을 숨기고 feed widget을 재사용한다`() {
val layout = homeMainLayoutSource()
val recentNewsSection = layout.substringAfter("@+id/ll_home_following_recent_news_section")
.substringBefore("</LinearLayout>")
@@ -215,10 +211,13 @@ class HomeFollowingFragmentSourceTest {
assertTrue(adapter.contains("FeedItem.Content"))
assertFalse(adapter.contains("R.layout.item_home_following_news_content"))
assertFalse(adapter.contains("R.layout.item_home_following_news_rank"))
assertTrue(recentNewsSection.contains("tools:listitem=\"@layout/view_feed_content\""))
assertTrue(projectFileOrNull("app/src/main/res/layout/item_home_following_news_content.xml") == null)
assertTrue(projectFileOrNull("app/src/main/res/layout/item_home_following_news_rank.xml") == null)
}
@Test
fun `following ranking news builds sentence message and highlights rank text`() {
fun `팔로잉 ranking 소식은 문장 message와 순위 강조를 만든다`() {
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeFollowingNewsAdapter.kt"
).readText()
@@ -229,14 +228,15 @@ class HomeFollowingFragmentSourceTest {
assertTrue(adapter.contains("context.getString("))
assertTrue(adapter.contains("highlightRanges = listOf("))
assertTrue(adapter.contains("FeedRankHighlight("))
assertTrue(adapter.contains("start = message.indexOf(rankText)"))
assertTrue(adapter.contains("endExclusive = message.indexOf(rankText) + rankText.length"))
assertTrue(adapter.contains("start = message.lastIndexOf(rankText)"))
assertTrue(adapter.contains("endExclusive = message.lastIndexOf(rankText) + rankText.length"))
assertTrue(adapter.contains("R.string.screen_home_following_ranking_rank_format"))
assertFalse(adapter.contains("message = ranking.body.ifBlank { ranking.title }"))
assertFalse(adapter.contains("highlightRanges = emptyList()"))
}
@Test
fun `following recent news models use nested payload response contract`() {
fun `팔로잉 최근 소식 model nested payload 응답 계약을 사용한다`() {
val models = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/data/HomeFollowingModels.kt"
).readText()
@@ -266,7 +266,7 @@ class HomeFollowingFragmentSourceTest {
}
@Test
fun `following recent news adapter binds nested payload ui variants`() {
fun `팔로잉 최근 소식 adapter nested payload UI variant를 바인딩한다`() {
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeFollowingNewsAdapter.kt"
).readText()
@@ -289,20 +289,34 @@ class HomeFollowingFragmentSourceTest {
}
@Test
fun `following creators section has no header and uses figma simple profile size`() {
fun `팔로잉 최근 소식 feed model은 미제공 creator id를 합성하지 않는다`() {
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeFollowingNewsAdapter.kt"
).readText()
assertFalse(adapter.contains("creatorId = community.targetId.toString()"))
assertFalse(adapter.contains("creatorId = content.targetId.toString()"))
assertTrue(adapter.contains("creatorId = \"\""))
assertTrue(adapter.contains("postId = community.postId.toString()"))
assertTrue(adapter.contains("contentId = content.contentId.toString()"))
assertTrue(adapter.contains("view.setOnFeedClick { onClickItem(community) }"))
assertTrue(adapter.contains("view.setOnFeedClick { onClickItem(content) }"))
}
@Test
fun `팔로잉 크리에이터 섹션은 header 없이 item layout을 사용한다`() {
val layout = homeMainLayoutSource()
val section = layout.substringAfter("@+id/ll_home_following_creators_section")
.substringBefore("@+id/ll_home_following_on_air_section")
val itemLayout = projectFile("app/src/main/res/layout/item_home_following_creator.xml").readText()
assertFalse(section.contains("view_home_following_creators_title"))
assertTrue(itemLayout.contains("android:layout_width=\"75dp\""))
assertTrue(itemLayout.contains("android:layout_height=\"75dp\""))
assertTrue(itemLayout.contains("@style/Typography.Body5"))
assertTrue(itemLayout.contains("@+id/iv_home_following_creator_profile"))
assertTrue(itemLayout.contains("@+id/tv_home_following_creator_nickname"))
}
@Test
fun `following creators list appends all button as last item`() {
fun `팔로잉 크리에이터 목록은 전체 item을 마지막에 추가하고 이동을 연결한다`() {
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeFollowingCreatorAdapter.kt"
).readText()
@@ -324,48 +338,36 @@ class HomeFollowingFragmentSourceTest {
assertTrue(fragment.contains("onClickAll = { openFollowingCreatorAll() }"))
assertTrue(fragment.contains("private fun openFollowingCreatorAll()"))
assertTrue(fragment.contains("startActivity(Intent(requireContext(), FollowingCreatorActivity::class.java))"))
assertTrue(allLayout.contains("android:layout_width=\"wrap_content\""))
assertTrue(allLayout.contains("android:layout_height=\"wrap_content\""))
assertTrue(allLayout.contains("android:layout_height=\"75dp\""))
assertTrue(allLayout.contains("android:paddingHorizontal=\"16dp\""))
assertTrue(allLayout.contains("android:textColor=\"@color/soda_400\""))
assertTrue(allLayout.contains("android:visibility=\"invisible\""))
assertTrue(allLayout.contains("@string/screen_home_theme_all"))
}
@Test
fun `following recent chats section is horizontal box list`() {
fun `팔로잉 최근 대화 섹션은 RecyclerView와 adapter를 연결한다`() {
val layout = homeMainLayoutSource()
val section = layout.substringAfter("@+id/rv_home_following_recent_chats")
.substringBefore("@+id/ll_home_following_monthly_schedules_section")
val fragment = homeMainFragmentSource()
assertTrue(section.contains("android:orientation=\"horizontal\""))
assertTrue(layout.contains("@+id/rv_home_following_recent_chats"))
assertTrue(fragment.contains("rvHomeFollowingRecentChats.apply"))
assertTrue(fragment.contains("LinearLayoutManager.HORIZONTAL"))
}
@Test
fun `following recent chat item matches figma box fields`() {
fun `팔로잉 최근 대화 item은 필수 field와 클릭 계약을 바인딩한다`() {
val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeFollowingChatAdapter.kt"
).readText()
val itemLayout = projectFile("app/src/main/res/layout/item_home_following_chat.xml").readText()
assertTrue(itemLayout.contains("android:layout_width=\"284dp\""))
assertTrue(itemLayout.contains("android:layout_width=\"62dp\""))
assertTrue(itemLayout.contains("android:layout_height=\"62dp\""))
assertTrue(itemLayout.contains("@+id/iv_home_following_chat_creator_profile"))
assertTrue(itemLayout.contains("@+id/tv_home_following_chat_creator_nickname"))
assertTrue(itemLayout.contains("@+id/tv_home_following_chat_message"))
assertTrue(itemLayout.contains("@+id/tv_home_following_chat_time"))
assertTrue(itemLayout.contains("@+id/tv_home_following_chat_direct_badge"))
assertTrue(itemLayout.contains("@drawable/bg_chat_direct_badge"))
assertTrue(itemLayout.contains("@string/screen_chat_direct_badge"))
val messageText = itemLayout.substringAfter("@+id/tv_home_following_chat_message")
.substringBefore("@+id/tv_home_following_chat_creator_nickname")
assertTrue(messageText.contains("android:maxLines=\"1\""))
assertTrue(messageText.contains("android:ellipsize=\"end\""))
assertFalse(adapter.contains("ViewGroup.LayoutParams.MATCH_PARENT"))
assertTrue(adapter.contains("recyclerItemLayoutParams(parent)"))
assertTrue(adapter.contains("directBadgeText.visibility = if (item.showDirectBadge)"))
assertTrue(adapter.contains("formatChatRoomLastMessageTime(itemView.context, item.lastMessageAt)"))
assertTrue(adapter.contains("itemView.setOnClickListener { onClickItem(item) }"))
}
private fun homeMainFragmentSource(): String {

View File

@@ -1,6 +1,5 @@
package kr.co.vividnext.sodalive.v2.main.home
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.v2.common.CreatorActivityType
import kr.co.vividnext.sodalive.v2.main.chat.data.ChatRoomListItemResponse
@@ -75,16 +74,6 @@ class HomeFollowingMapperTest {
assertEquals(listOf("second", "first"), state.monthlySchedules.items.map { it.scheduleId })
}
@Test
fun `PHOTO_CONTENT는 photo content label로 매핑된다`() {
val state = response(
recentNews = listOf(photoContentNews())
).toUiState(formatter) as HomeFollowingUiState.Content
val item = state.recentNews.items.single() as HomeFollowingNewsUiItem.Content
assertEquals(R.string.screen_home_following_photo_content, item.labelResId)
}
@Test
fun `recentNews nested payload는 type별 UI item으로 매핑된다`() {
val state = response(
@@ -121,7 +110,6 @@ class HomeFollowingMapperTest {
val photoContent = state.recentNews.items[3] as HomeFollowingNewsUiItem.Content
assertEquals(FollowingNewsType.PHOTO_CONTENT, photoContent.type)
assertEquals(3002L, photoContent.contentId)
assertEquals(R.string.screen_home_following_photo_content, photoContent.labelResId)
val community = state.recentNews.items[4] as HomeFollowingNewsUiItem.Community
assertEquals(4001L, community.postId)

View File

@@ -0,0 +1,138 @@
package kr.co.vividnext.sodalive.v2.main.home
import android.app.Application
import android.content.Context
import android.content.res.Configuration
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.test.core.app.ApplicationProvider
import coil.load
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.ImageLoaderProvider
import kr.co.vividnext.sodalive.v2.main.home.data.FollowingNewsType
import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingNewsUiItem
import kr.co.vividnext.sodalive.v2.main.home.ui.HomeFollowingNewsAdapter
import kr.co.vividnext.sodalive.v2.main.home.ui.buildHomeFollowingRankingFeedItem
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.util.Locale
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
class HomeFollowingNewsAdapterTest {
@Before
fun setUp() {
if (!ImageLoaderProvider.isInitialized) {
ImageLoaderProvider.init(RuntimeEnvironment.getApplication())
}
}
@Test
fun `ranking 문장은 locale별 순위 단위를 사용하고 같은 문자열을 강조한다`() {
assertRankingText(locale = Locale.KOREAN, expectedRankText = "7위")
assertRankingText(locale = Locale.ENGLISH, expectedRankText = "No. 7")
assertRankingText(locale = Locale.JAPANESE, expectedRankText = "7位")
}
@Test
fun `ranking 제목에 같은 순위 문자열이 있어도 삽입된 순위 문자열을 강조한다`() {
val feedItem = buildHomeFollowingRankingFeedItem(
localizedContext(Locale.KOREAN),
ranking(creatorNickname = "7위 크리에이터")
)
val highlight = feedItem.highlightRanges.single()
assertEquals(feedItem.message.lastIndexOf("7위"), highlight.start)
assertEquals("7위", feedItem.message.substring(highlight.start, highlight.endExclusive))
}
@Test
fun `nullable content 이미지는 재사용된 ImageView의 이전 Coil 요청을 해제한다`() {
val adapter = HomeFollowingNewsAdapter()
adapter.submitItems(listOf(content(contentImageUrl = null)))
val parent = FrameLayout(RuntimeEnvironment.getApplication()).apply {
layoutParams = ViewGroup.LayoutParams(360, ViewGroup.LayoutParams.WRAP_CONTENT)
}
val holder = adapter.onCreateViewHolder(parent, adapter.getItemViewType(0))
val imageView = holder.itemView.findViewById<ImageView>(R.id.iv_feed_content_image)
val disposable = imageView.load(ColorDrawable(Color.BLUE), ImageLoaderProvider.imageLoader)
assertFalse(disposable.isDisposed)
adapter.onBindViewHolder(holder, 0)
assertTrue(disposable.isDisposed)
assertNull(imageView.drawable)
}
@Test
fun `PHOTO_CONTENT는 Feed photo category label로 표시된다`() {
val adapter = HomeFollowingNewsAdapter()
adapter.submitItems(listOf(content(type = FollowingNewsType.PHOTO_CONTENT)))
val parent = FrameLayout(RuntimeEnvironment.getApplication()).apply {
layoutParams = ViewGroup.LayoutParams(360, ViewGroup.LayoutParams.WRAP_CONTENT)
}
val holder = adapter.onCreateViewHolder(parent, adapter.getItemViewType(0))
adapter.onBindViewHolder(holder, 0)
val categoryText = holder.itemView.findViewById<TextView>(R.id.tv_feed_content_category)
assertEquals(parent.context.getString(R.string.screen_home_following_photo_content), categoryText.text.toString())
}
private fun assertRankingText(locale: Locale, expectedRankText: String) {
val feedItem = buildHomeFollowingRankingFeedItem(localizedContext(locale), ranking())
assertEquals("7", feedItem.rankText)
assertFalse(feedItem.message.contains("7위") && expectedRankText != "7위")
val highlight = feedItem.highlightRanges.single()
assertEquals(expectedRankText, feedItem.message.substring(highlight.start, highlight.endExclusive))
}
private fun localizedContext(locale: Locale): Context {
val context = ApplicationProvider.getApplicationContext<Context>()
val configuration = Configuration(context.resources.configuration)
configuration.setLocale(locale)
return context.createConfigurationContext(configuration)
}
private fun ranking(creatorNickname: String = "creator") = HomeFollowingNewsUiItem.Ranking(
newsId = "news-1",
type = FollowingNewsType.CREATOR_RANKING,
targetId = 1L,
visibleFromAtUtc = "2026-07-30T00:00:00Z",
visibleFromText = "1분 전",
rank = 7,
imageUrl = "https://example.com/rank.png",
title = "ranking title",
creatorNickname = creatorNickname
)
private fun content(
contentImageUrl: String? = "https://example.com/content.png",
type: FollowingNewsType = FollowingNewsType.AUDIO_CONTENT
) = HomeFollowingNewsUiItem.Content(
newsId = "news-content",
type = type,
targetId = 10L,
visibleFromAtUtc = "2026-07-30T00:00:00Z",
visibleFromText = "1분 전",
contentId = 10L,
contentImageUrl = contentImageUrl,
title = "content title",
creatorProfileImageUrl = "https://example.com/profile.png",
creatorNickname = "creator"
)
}

View File

@@ -56,10 +56,16 @@ class HomeFollowingViewModelTest {
@Test
fun `loadFollowing은 loading 후 content 상태를 발행한다`() {
api.enqueueSuccess(response(followingCreators = listOf(creator())))
val states = mutableListOf<HomeFollowingUiState>()
val observer = Observer<HomeFollowingUiState> { states.add(it) }
viewModel.followingStateLiveData.observeForever(observer)
viewModel.loadFollowing()
viewModel.followingStateLiveData.removeObserver(observer)
val state = viewModel.followingStateLiveData.requireValue() as HomeFollowingUiState.Content
assertTrue(states.first() is HomeFollowingUiState.Loading)
assertTrue(states.last() is HomeFollowingUiState.Content)
assertEquals(1L, state.followingCreators.items.single().creatorId)
assertFalse(viewModel.isLoading.requireValue() ?: true)
}

View File

@@ -57,11 +57,11 @@ class HomeMainFragmentLoginGuardSourceTest {
).readText()
val clickSource = source.substringFrom("private fun onFollowingNewsClick(item: HomeFollowingNewsUiItem)")
assertTrue(clickSource.contains("val community = item as? HomeFollowingNewsUiItem.Community ?: return"))
assertTrue(clickSource.contains("val postId = community.postId"))
assertTrue(clickSource.contains("is HomeFollowingNewsUiItem.Community -> {"))
assertTrue(clickSource.contains("val postId = item.postId"))
assertBefore(
clickSource,
"val community = item as? HomeFollowingNewsUiItem.Community ?: return",
"is HomeFollowingNewsUiItem.Community -> {",
"handleCommunityAction"
)
assertBefore(
@@ -75,6 +75,34 @@ class HomeMainFragmentLoginGuardSourceTest {
assertFalse(clickSource.contains("EXTRA_COMMUNITY_POST_ID"))
}
@Test
fun `HomeMainFragment 팔로잉 최근 소식 CREATOR_RANKING은 크리에이터 채널로 이동한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt"
).readText()
val clickSource = source.substringFrom("private fun onFollowingNewsClick(item: HomeFollowingNewsUiItem)")
assertTrue(clickSource.contains("is HomeFollowingNewsUiItem.Ranking ->"))
assertTrue(clickSource.contains("item.type == FollowingNewsType.CREATOR_RANKING"))
assertTrue(clickSource.contains("handleCreatorAction(CreatorActionCommand.Profile(item.targetId))"))
}
@Test
fun `HomeMainFragment 팔로잉 최근 소식 AUDIO_CONTENT는 오디오 상세로 이동한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt"
).readText()
val clickSource = source.substringFrom("private fun onFollowingNewsClick(item: HomeFollowingNewsUiItem)")
assertTrue(clickSource.contains("is HomeFollowingNewsUiItem.Content ->"))
assertTrue(clickSource.contains("item.type == FollowingNewsType.AUDIO_CONTENT"))
assertTrue(
clickSource.contains(
"handleContentAction(ContentActionCommand.AudioDetail(audioContentId = item.contentId))"
)
)
}
@Test
fun `HomeMainFragment 팔로잉 탭 선택은 로그인 가드 통과 후 탭을 전환한다`() {
val source = projectFile(
@@ -125,6 +153,34 @@ class HomeMainFragmentLoginGuardSourceTest {
assertTrue(source.contains("private val liveActionCoordinator: LiveActionCoordinator by lazy"))
}
@Test
fun `HomeMainFragment 팔로잉 On Air 아이템은 공통 Live Action으로 입장한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt"
).readText()
val clickSource = source.substringFrom("private fun onFollowingLiveClick(item: HomeFollowingLiveUiItem)")
assertTrue(clickSource.contains("liveActionCoordinator.enterLiveRoom(item.liveId)"))
assertFalse(clickSource.contains("= Unit"))
}
@Test
fun `HomeMainFragment 팔로잉 스케줄은 type별 공통 Action으로 이동한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt"
).readText()
val clickSource = source.substringFrom("private fun onFollowingScheduleClick(item: HomeFollowingScheduleUiItem)")
assertTrue(clickSource.contains("if (item.targetId <= 0L) return"))
assertTrue(clickSource.contains("CreatorActivityType.Live -> liveActionCoordinator.enterLiveRoom(item.targetId)"))
assertTrue(clickSource.contains("CreatorActivityType.LiveReplay"))
assertTrue(clickSource.contains("CreatorActivityType.Audio -> handleContentAction("))
assertTrue(clickSource.contains("ContentActionCommand.AudioDetail(audioContentId = item.targetId)"))
assertTrue(clickSource.contains("CreatorActivityType.Community -> handleCommunityAction("))
assertTrue(clickSource.contains("CommunityActionCommand.PostDetail(item.targetId)"))
assertFalse(clickSource.contains("Intent("))
}
@Test
fun `HomeMainFragment 최근 활동 LIVE route는 공통 Live와 Creator Action을 사용한다`() {
val source = projectFile(

View File

@@ -42,27 +42,6 @@ class FeedViewTest {
assertSame(category.parent, time.parent)
}
@Test
fun `content layout matches figma compact upload notice dimensions`() {
val layout = projectFile("app/src/main/res/layout/view_feed_content.xml").readText()
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedContentView.kt"
).readText()
val imageBlock = layout.substringAfter("@+id/iv_feed_content_image")
.substringBefore("@+id/ll_feed_content_info")
val infoBlock = layout.substringAfter("@+id/ll_feed_content_info")
.substringBefore("</kr.co.vividnext.sodalive.v2.widget.feed.FeedContentView>")
val titleBlock = layout.substringAfter("@+id/tv_feed_content_title")
.substringBefore("@+id/ll_feed_content_meta")
assertTrue(layout.contains("android:padding=\"@dimen/spacing_14\""))
assertTrue(imageBlock.contains("android:layout_width=\"88dp\""))
assertTrue(imageBlock.contains("android:layout_height=\"88dp\""))
assertTrue(infoBlock.contains("android:layout_marginStart=\"@dimen/spacing_14\""))
assertTrue(titleBlock.contains("android:layout_marginTop=\"@dimen/spacing_6\""))
assertTrue(source.contains("const val CONTENT_IMAGE_WIDTH_DP = 88"))
}
@Test
fun `content root clipping is implemented in custom view code`() {
val layout = projectFile("app/src/main/res/layout/view_feed_content.xml").readText()