Feat: Add News tab and related functionality

- Added a "News" tab to the main index screen.
- Implemented API parameters for fetching news-specific posts: `imageTag`, `search`, `advancedSearch`, `newsFilter`, `onlyNews`, `newsSource`, `newsLanguage`, `newsCategory`, `requireImageCache`.
- Updated `Moment` data class and `MomentEntity` to include news-related fields like `isNews`, `newsTitle`, `newsUrl`, etc.
- Created `News.kt` composable and `NewsViewModel.kt` to display and manage news items.
- Updated `MomentLoader` to include a `newsOnly` parameter for fetching only news items.
- Added Japanese translations for new index tab strings: "Worldwide", "Dynamic", "Following", "Hot", and "News".
- Adjusted tab count and layout based on guest/logged-in user status to accommodate the new "News" tab.
This commit is contained in:
2025-09-15 18:31:24 +08:00
parent 68273ae166
commit a1196715d0
8 changed files with 318 additions and 7 deletions

View File

@@ -32,6 +32,28 @@ data class Moment(
val time: String, val time: String,
@SerializedName("isFollowed") @SerializedName("isFollowed")
val isFollowed: Boolean, val isFollowed: Boolean,
@SerializedName("isNews")
val isNews: Boolean? = null,
@SerializedName("newsTitle")
val newsTitle: String? = null,
@SerializedName("newsUrl")
val newsUrl: String? = null,
@SerializedName("newsSource")
val newsSource: String? = null,
@SerializedName("newsCategory")
val newsCategory: String? = null,
@SerializedName("newsLanguage")
val newsLanguage: String? = null,
@SerializedName("newsContent")
val newsContent: String? = null,
@SerializedName("hasFullText")
val hasFullText: Boolean? = null,
@SerializedName("summary")
val summary: String? = null,
@SerializedName("publishedAt")
val publishedAt: String? = null,
@SerializedName("imageCached")
val imageCached: Boolean? = null,
) { ) {
fun toMomentItem(): MomentEntity { fun toMomentItem(): MomentEntity {
return MomentEntity( return MomentEntity(
@@ -60,6 +82,17 @@ data class Moment(
authorId = user.id.toInt(), authorId = user.id.toInt(),
liked = isLiked, liked = isLiked,
isFavorite = isFavorite, isFavorite = isFavorite,
isNews = isNews,
newsTitle = newsTitle,
newsUrl = newsUrl,
newsSource = newsSource,
newsCategory = newsCategory,
newsLanguage = newsLanguage,
newsContent = newsContent,
hasFullText = hasFullText,
summary = summary,
publishedAt = publishedAt,
imageCached = imageCached,
) )
} }
} }

View File

@@ -316,6 +316,7 @@ interface RaveNowAPI {
suspend fun getPosts( suspend fun getPosts(
@Query("page") page: Int = 1, @Query("page") page: Int = 1,
@Query("pageSize") pageSize: Int = 20, @Query("pageSize") pageSize: Int = 20,
@Query("id") postId: Int? = null,
@Query("timelineId") timelineId: Int? = null, @Query("timelineId") timelineId: Int? = null,
@Query("authorId") authorId: Int? = null, @Query("authorId") authorId: Int? = null,
@Query("contentSearch") contentSearch: String? = null, @Query("contentSearch") contentSearch: String? = null,
@@ -323,6 +324,15 @@ interface RaveNowAPI {
@Query("trend") trend: String? = null, @Query("trend") trend: String? = null,
@Query("favouriteUserId") favouriteUserId: Int? = null, @Query("favouriteUserId") favouriteUserId: Int? = null,
@Query("explore") explore: String? = null, @Query("explore") explore: String? = null,
@Query("imageTag") imageTag: String? = null,
@Query("search") search: String? = null,
@Query("advancedSearch") advancedSearch: String? = null,
@Query("newsFilter") newsFilter: String? = null,
@Query("onlyNews") onlyNews: Boolean? = null,
@Query("newsSource") newsSource: String? = null,
@Query("newsLanguage") newsLanguage: String? = null,
@Query("newsCategory") newsCategory: String? = null,
@Query("requireImageCache") requireImageCache: Boolean? = null,
): Response<ListContainer<Moment>> ): Response<ListContainer<Moment>>
@Multipart @Multipart

View File

@@ -299,12 +299,35 @@ data class MomentEntity(
// 关联动态 // 关联动态
var relMoment: MomentEntity? = null, var relMoment: MomentEntity? = null,
// 是否收藏 // 是否收藏
var isFavorite: Boolean = false var isFavorite: Boolean = false,
// 是否为新闻
val isNews: Boolean? = null,
// 新闻标题
val newsTitle: String? = null,
// 新闻链接
val newsUrl: String? = null,
// 新闻来源
val newsSource: String? = null,
// 新闻分类
val newsCategory: String? = null,
// 新闻语言
val newsLanguage: String? = null,
// 新闻内容
val newsContent: String? = null,
// 是否有完整文本
val hasFullText: Boolean? = null,
// 摘要
val summary: String? = null,
// 发布时间
val publishedAt: String? = null,
// 图片是否已缓存
val imageCached: Boolean? = null
) )
class MomentLoaderExtraArgs( class MomentLoaderExtraArgs(
val explore: Boolean? = false, val explore: Boolean? = false,
val timelineId: Int? = null, val timelineId: Int? = null,
val authorId : Int? = null val authorId : Int? = null,
val newsOnly: Boolean? = false
) )
class MomentLoader : DataLoader<MomentEntity,MomentLoaderExtraArgs>() { class MomentLoader : DataLoader<MomentEntity,MomentLoaderExtraArgs>() {
override suspend fun fetchData( override suspend fun fetchData(
@@ -317,7 +340,8 @@ class MomentLoader : DataLoader<MomentEntity,MomentLoaderExtraArgs>() {
pageSize = pageSize, pageSize = pageSize,
explore = if (extra.explore == true) "true" else "", explore = if (extra.explore == true) "true" else "",
timelineId = extra.timelineId, timelineId = extra.timelineId,
authorId = extra.authorId authorId = extra.authorId,
newsFilter = if (extra.newsOnly == true) "news_only" else ""
) )
val data = result.body()?.let { val data = result.body()?.let {
ListContainer( ListContainer(

View File

@@ -44,6 +44,7 @@ import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.dynamic.Dynamic import com.aiosman.ravenow.ui.index.tabs.moment.tabs.dynamic.Dynamic
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.expolre.Explore import com.aiosman.ravenow.ui.index.tabs.moment.tabs.expolre.Explore
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.hot.HotMomentsList import com.aiosman.ravenow.ui.index.tabs.moment.tabs.hot.HotMomentsList
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.news.News
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.timeline.TimelineMomentsList import com.aiosman.ravenow.ui.index.tabs.moment.tabs.timeline.TimelineMomentsList
import com.aiosman.ravenow.ui.index.tabs.search.SearchViewModel import com.aiosman.ravenow.ui.index.tabs.search.SearchViewModel
import com.aiosman.ravenow.ui.modifiers.noRippleClickable import com.aiosman.ravenow.ui.modifiers.noRippleClickable
@@ -66,8 +67,8 @@ fun MomentsList() {
val navigationBarPaddings = val navigationBarPaddings =
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 48.dp WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 48.dp
val statusBarPaddingValues = WindowInsets.systemBars.asPaddingValues() val statusBarPaddingValues = WindowInsets.systemBars.asPaddingValues()
// 游客模式下显示timeline只显示2个tabDynamic、Hot // 游客模式下不显示timeline只显示3个tabExplore、Dynamic、Hot // 游客模式下显示3个tabWorldwide、Hot、News非游客模式显示4个tabWorldwide、Following、Hot、News
val tabCount = if (AppStore.isGuest) 2 else 3 // val tabCount = if (AppStore.isGuest) 3 else 4 val tabCount = if (AppStore.isGuest) 3 else 4
var pagerState = rememberPagerState { tabCount } var pagerState = rememberPagerState { tabCount }
var scope = rememberCoroutineScope() var scope = rememberCoroutineScope()
Column( Column(
@@ -204,6 +205,23 @@ fun MomentsList() {
} }
) )
} }
TabSpacer()
// 新闻标签
Box {
CustomTabItem(
text = stringResource(R.string.index_news),
isSelected = pagerState.currentPage == 3,
onClick = {
tabDebouncer {
scope.launch {
pagerState.animateScrollToPage(3)
}
}
}
)
}
} else { } else {
// 热门标签 (游客模式) // 热门标签 (游客模式)
Box { Box {
@@ -219,6 +237,23 @@ fun MomentsList() {
} }
) )
} }
TabSpacer()
// 新闻标签 (游客模式)
Box {
CustomTabItem(
text = stringResource(R.string.index_news),
isSelected = pagerState.currentPage == 2,
onClick = {
tabDebouncer {
scope.launch {
pagerState.animateScrollToPage(2)
}
}
}
)
}
} }
} }
@@ -229,7 +264,7 @@ fun MomentsList() {
.weight(1f) .weight(1f)
) { ) {
if (AppStore.isGuest) { if (AppStore.isGuest) {
// 游客模式:Dynamic(0), Hot(1) // 游客模式:Worldwide(0), Hot(1), News(2)
when (it) { when (it) {
0 -> { 0 -> {
Dynamic() Dynamic()
@@ -237,9 +272,12 @@ fun MomentsList() {
1 -> { 1 -> {
HotMomentsList() HotMomentsList()
} }
2 -> {
News()
}
} }
} else { } else {
// 正常用户:Dynamic(0), Timeline(1), Hot(2) // 正常用户:Worldwide(0), Following(1), Hot(2), News(3)
when (it) { when (it) {
0 -> { 0 -> {
Dynamic() Dynamic()
@@ -250,6 +288,9 @@ fun MomentsList() {
2 -> { 2 -> {
HotMomentsList() HotMomentsList()
} }
3 -> {
News()
}
} }
} }
} }

View File

@@ -0,0 +1,179 @@
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.news
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
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.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.aiosman.ravenow.GuestLoginCheckOut
import com.aiosman.ravenow.GuestLoginCheckOutScene
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.composables.MomentCard
import com.aiosman.ravenow.ui.composables.rememberDebouncer
import kotlinx.coroutines.launch
/**
* 新闻动态列表
*/
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun News() {
val model = NewsViewModel
val moments = model.moments
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
val state = rememberPullRefreshState(model.refreshing, onRefresh = {
model.refreshPager(
pullRefresh = true
)
})
val listState = rememberLazyListState()
// 用于跟踪是否已经触发过加载更多
var hasTriggeredLoadMore by remember { mutableStateOf(false) }
// observe list scrolling with simplified logic
val reachedBottom by remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull()
val totalItems = layoutInfo.totalItemsCount
if (lastVisibleItem == null || totalItems == 0) {
false
} else {
val isLastItemVisible = lastVisibleItem.index >= totalItems - 2
// 简化逻辑:只要滑动到底部且还没有触发过,就触发加载
isLastItemVisible && !hasTriggeredLoadMore
}
}
}
// load more if scrolled to bottom
LaunchedEffect(reachedBottom) {
if (reachedBottom) {
hasTriggeredLoadMore = true
model.loadMore()
}
}
LaunchedEffect(Unit) {
model.refreshPager()
}
// 监听数据变化,重置加载状态
LaunchedEffect(moments.size) {
if (moments.size > 0) {
// 延迟重置,确保数据已经稳定
kotlinx.coroutines.delay(500)
hasTriggeredLoadMore = false
}
}
Column(
modifier = Modifier
.fillMaxSize()
) {
Box(Modifier.pullRefresh(state)) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState
) {
items(
moments.size,
key = { idx -> idx }
) { idx ->
//处理下标越界
if (idx < 0 || idx >= moments.size) return@items
val momentItem = moments[idx] ?: return@items
val commentDebouncer = rememberDebouncer()
val likeDebouncer = rememberDebouncer()
val favoriteDebouncer = rememberDebouncer()
val followDebouncer = rememberDebouncer()
MomentCard(
momentEntity = momentItem,
onAddComment = {
commentDebouncer {
// 检查游客模式,如果是游客则跳转登录
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.COMMENT_MOMENT)) {
navController.navigate(NavigationRoute.Login.route)
} else {
scope.launch {
model.onAddComment(momentItem.id)
}
}
}
},
onLikeClick = {
likeDebouncer {
// 检查游客模式,如果是游客则跳转登录
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) {
navController.navigate(NavigationRoute.Login.route)
} else {
scope.launch {
if (momentItem.liked) {
model.dislikeMoment(momentItem.id)
} else {
model.likeMoment(momentItem.id)
}
}
}
}
},
onFavoriteClick = {
favoriteDebouncer {
// 检查游客模式,如果是游客则跳转登录
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) {
navController.navigate(NavigationRoute.Login.route)
} else {
scope.launch {
if (momentItem.isFavorite) {
model.unfavoriteMoment(momentItem.id)
} else {
model.favoriteMoment(momentItem.id)
}
}
}
}
},
onFollowClick = {
followDebouncer {
// 检查游客模式,如果是游客则跳转登录
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.FOLLOW_USER)) {
navController.navigate(NavigationRoute.Login.route)
} else {
model.followAction(momentItem)
}
}
},
showFollowButton = true
)
}
}
PullRefreshIndicator(
refreshing = model.refreshing,
state = state,
modifier = Modifier.align(Alignment.TopCenter)
)
}
}
}

View File

@@ -0,0 +1,16 @@
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.news
import com.aiosman.ravenow.entity.MomentLoaderExtraArgs
import com.aiosman.ravenow.ui.index.tabs.moment.BaseMomentModel
import org.greenrobot.eventbus.EventBus
object NewsViewModel : BaseMomentModel() {
init {
EventBus.getDefault().register(this)
}
override fun extraArgs(): MomentLoaderExtraArgs {
return MomentLoaderExtraArgs(explore = true, newsOnly = true)
}
}

View File

@@ -26,4 +26,11 @@
<string name="create_agent_v2_ai_enhance_icon_desc">AI美化アイコン</string> <string name="create_agent_v2_ai_enhance_icon_desc">AI美化アイコン</string>
<string name="create_agent_v2_edit_icon_desc">編集アイコン</string> <string name="create_agent_v2_edit_icon_desc">編集アイコン</string>
<string name="create_agent_v2_select_avatar_desc">アバター選択</string> <string name="create_agent_v2_select_avatar_desc">アバター選択</string>
<!-- Index tabs -->
<string name="index_worldwide">ワールドワイド</string>
<string name="index_dynamic">ダイナミック</string>
<string name="index_following">フォロー中</string>
<string name="index_hot">ホット</string>
<string name="index_news">ニュース</string>
</resources> </resources>

View File

@@ -122,6 +122,7 @@
<string name="index_dynamic">Dynamic</string> <string name="index_dynamic">Dynamic</string>
<string name="index_following">Following</string> <string name="index_following">Following</string>
<string name="index_hot">Hot</string> <string name="index_hot">Hot</string>
<string name="index_news">News</string>
<string name="main_home">Home</string> <string name="main_home">Home</string>
<string name="main_ai">Agent</string> <string name="main_ai">Agent</string>
<string name="main_message">Message</string> <string name="main_message">Message</string>