feat: 新增推荐信息流功能
- 新增推荐(Recommend)信息流,支持多种内容类型,并替换原有的新闻(News)页面入口。
- 实现推荐服务(`RecommendationService`)及相关数据模型,用于从后端获取和解析推荐数据。
- 实现了三种推荐内容卡片:
- `PromptRecommendationItem`: AI Agent 推荐卡片。
- `PostRecommendationItem`: 普通图文动态推荐卡片。
- `VideoRecommendationItem`: 短视频动态推荐卡片。
- 在 `RecommendViewModel` 中实现了统一的数据加载、状态管理和用户交互逻辑(如点赞、收藏)。
- 扩展了 `MomentEntity` 和 `MomentImageEntity` 等数据模型,以支持更丰富的图片URL和处理空值情况。
This commit is contained in:
@@ -123,12 +123,16 @@ data class Comment(
|
||||
fun toCommentEntity(): CommentEntity {
|
||||
return CommentEntity(
|
||||
id = id,
|
||||
name = user.nickName,
|
||||
name = user.nickName ?: "未知用户",
|
||||
comment = content,
|
||||
date = ApiClient.dateFromApiString(createdAt),
|
||||
likes = likeCount,
|
||||
postId = postId,
|
||||
avatar = "${ApiClient.BASE_SERVER}${user.avatar}",
|
||||
avatar = if (user.avatar != null && user.avatar.isNotEmpty()) {
|
||||
"${ApiClient.BASE_SERVER}${user.avatar}"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
author = user.id,
|
||||
liked = isLiked,
|
||||
unread = isUnread,
|
||||
|
||||
@@ -34,7 +34,7 @@ data class Moment(
|
||||
@SerializedName("commentCount")
|
||||
val commentCount: Long,
|
||||
@SerializedName("time")
|
||||
val time: String,
|
||||
val time: String?,
|
||||
@SerializedName("isFollowed")
|
||||
val isFollowed: Boolean,
|
||||
// 新闻相关字段
|
||||
@@ -64,10 +64,18 @@ data class Moment(
|
||||
fun toMomentItem(): MomentEntity {
|
||||
return MomentEntity(
|
||||
id = id.toInt(),
|
||||
avatar = "${ApiClient.BASE_SERVER}${user.avatar}",
|
||||
nickname = user.nickName,
|
||||
avatar = if (user.avatar != null && user.avatar.isNotEmpty()) {
|
||||
"${ApiClient.BASE_SERVER}${user.avatar}"
|
||||
} else {
|
||||
"" // 如果头像为空,使用空字符串
|
||||
},
|
||||
nickname = user.nickName ?: "未知用户", // 如果昵称为空,使用默认值
|
||||
location = "Worldwide",
|
||||
time = ApiClient.dateFromApiString(time),
|
||||
time = if (time != null && time.isNotEmpty()) {
|
||||
ApiClient.dateFromApiString(time)
|
||||
} else {
|
||||
java.util.Date() // 如果时间为空,使用当前时间作为默认值
|
||||
},
|
||||
followStatus = isFollowed,
|
||||
momentTextContent = textContent,
|
||||
momentPicture = R.drawable.default_moment_img,
|
||||
@@ -77,9 +85,18 @@ data class Moment(
|
||||
favoriteCount = favoriteCount.toInt(),
|
||||
images = images?.map {
|
||||
MomentImageEntity(
|
||||
url = "${ApiClient.BASE_SERVER}${it.url}",
|
||||
thumbnail = "${ApiClient.BASE_SERVER}${it.thumbnail}",
|
||||
id = it.id,
|
||||
url = "${ApiClient.BASE_SERVER}${it.url}",
|
||||
originalUrl = it.originalUrl,
|
||||
directUrl = it.directUrl,
|
||||
thumbnail = "${ApiClient.BASE_SERVER}${it.thumbnail}",
|
||||
thumbnailDirectUrl = it.thumbnailDirectUrl,
|
||||
small = it.small,
|
||||
smallDirectUrl = it.smallDirectUrl,
|
||||
medium = it.medium,
|
||||
mediumDirectUrl = it.mediumDirectUrl,
|
||||
large = it.large,
|
||||
largeDirectUrl = it.largeDirectUrl,
|
||||
blurHash = it.blurHash,
|
||||
width = it.width,
|
||||
height = it.height
|
||||
@@ -188,9 +205,9 @@ data class User(
|
||||
@SerializedName("id")
|
||||
val id: Long,
|
||||
@SerializedName("nickName")
|
||||
val nickName: String,
|
||||
val nickName: String?,
|
||||
@SerializedName("avatar")
|
||||
val avatar: String,
|
||||
val avatar: String?,
|
||||
@SerializedName("avatarMedium")
|
||||
val avatarMedium: String? = null,
|
||||
@SerializedName("avatarLarge")
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
package com.aiosman.ravenow.data
|
||||
|
||||
import com.aiosman.ravenow.data.api.ApiClient
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonSyntaxException
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* 推荐接口响应
|
||||
*/
|
||||
data class RecommendationsResponse(
|
||||
@SerializedName("success")
|
||||
val success: Boolean,
|
||||
@SerializedName("data")
|
||||
val data: List<RecommendationItem>
|
||||
)
|
||||
|
||||
/**
|
||||
* 推荐项
|
||||
*/
|
||||
data class RecommendationItem(
|
||||
@SerializedName("type")
|
||||
val type: String,
|
||||
@SerializedName("id")
|
||||
val id: Long,
|
||||
@SerializedName("data")
|
||||
val data: Any // 根据type字段动态解析为不同类型
|
||||
) {
|
||||
/**
|
||||
* 将data字段转换为PromptRecommendationData
|
||||
*/
|
||||
fun toPromptData(): PromptRecommendationData? {
|
||||
if (type != "prompt") return null
|
||||
return try {
|
||||
val gson = Gson()
|
||||
val jsonString = gson.toJson(data)
|
||||
gson.fromJson(jsonString, PromptRecommendationData::class.java)
|
||||
} catch (e: JsonSyntaxException) {
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将data字段转换为PostRecommendationData (Moment)
|
||||
*/
|
||||
fun toPostData(): PostRecommendationData? {
|
||||
if (type !in listOf("post_normal", "post_video", "post_news", "post_music")) return null
|
||||
return try {
|
||||
val gson = Gson()
|
||||
val jsonString = gson.toJson(data)
|
||||
gson.fromJson(jsonString, PostRecommendationData::class.java)
|
||||
} catch (e: JsonSyntaxException) {
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将data字段转换为RoomRecommendationData
|
||||
*/
|
||||
fun toRoomData(): RoomRecommendationData? {
|
||||
if (type != "room") return null
|
||||
return try {
|
||||
val gson = Gson()
|
||||
val jsonString = gson.toJson(data)
|
||||
gson.fromJson(jsonString, RoomRecommendationData::class.java)
|
||||
} catch (e: JsonSyntaxException) {
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt类型推荐数据
|
||||
*/
|
||||
data class PromptRecommendationData(
|
||||
@SerializedName("id")
|
||||
val id: Long,
|
||||
@SerializedName("title")
|
||||
val title: String,
|
||||
@SerializedName("desc")
|
||||
val desc: String,
|
||||
@SerializedName("createdAt")
|
||||
val createdAt: String,
|
||||
@SerializedName("updatedAt")
|
||||
val updatedAt: String,
|
||||
@SerializedName("avatar")
|
||||
val avatar: String? = null,
|
||||
@SerializedName("avatarDirectUrl")
|
||||
val avatarDirectUrl: String? = null,
|
||||
@SerializedName("author")
|
||||
val author: String? = null,
|
||||
@SerializedName("isPublic")
|
||||
val isPublic: Boolean = false,
|
||||
@SerializedName("openId")
|
||||
val openId: String,
|
||||
@SerializedName("breakMode")
|
||||
val breakMode: Boolean = false,
|
||||
@SerializedName("useCount")
|
||||
val useCount: Int? = null,
|
||||
@SerializedName("translations")
|
||||
val translations: Map<String, Map<String, String>>? = null,
|
||||
@SerializedName("translation")
|
||||
val translation: Map<String, String>? = null,
|
||||
@SerializedName("details")
|
||||
val details: PromptDetails? = null,
|
||||
@SerializedName("aiUserProfile")
|
||||
val aiUserProfile: AIUserProfile? = null,
|
||||
@SerializedName("creatorProfile")
|
||||
val creatorProfile: CreatorProfile? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Prompt详细信息
|
||||
*/
|
||||
data class PromptDetails(
|
||||
@SerializedName("gender")
|
||||
val gender: String? = null,
|
||||
@SerializedName("age")
|
||||
val age: Int? = null,
|
||||
@SerializedName("mbti")
|
||||
val mbti: String? = null,
|
||||
@SerializedName("constellation")
|
||||
val constellation: String? = null,
|
||||
@SerializedName("nickname")
|
||||
val nickname: String? = null,
|
||||
@SerializedName("birthday")
|
||||
val birthday: String? = null,
|
||||
@SerializedName("signature")
|
||||
val signature: String? = null,
|
||||
@SerializedName("nationality")
|
||||
val nationality: String? = null,
|
||||
@SerializedName("mainLanguage")
|
||||
val mainLanguage: String? = null,
|
||||
@SerializedName("worldview")
|
||||
val worldview: String? = null,
|
||||
@SerializedName("habits")
|
||||
val habits: String? = null,
|
||||
@SerializedName("hobbies")
|
||||
val hobbies: String? = null,
|
||||
@SerializedName("occupation")
|
||||
val occupation: String? = null,
|
||||
@SerializedName("expertise")
|
||||
val expertise: String? = null,
|
||||
@SerializedName("socialActivityLvl")
|
||||
val socialActivityLvl: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* AI用户资料
|
||||
*/
|
||||
data class AIUserProfile(
|
||||
@SerializedName("id")
|
||||
val id: Long,
|
||||
@SerializedName("username")
|
||||
val username: String? = null,
|
||||
@SerializedName("nickname")
|
||||
val nickname: String,
|
||||
@SerializedName("avatar")
|
||||
val avatar: String? = null,
|
||||
@SerializedName("avatarMedium")
|
||||
val avatarMedium: String? = null,
|
||||
@SerializedName("avatarLarge")
|
||||
val avatarLarge: String? = null,
|
||||
@SerializedName("avatarDirectUrl")
|
||||
val avatarDirectUrl: String? = null,
|
||||
@SerializedName("avatarMediumDirectUrl")
|
||||
val avatarMediumDirectUrl: String? = null,
|
||||
@SerializedName("avatarLargeDirectUrl")
|
||||
val avatarLargeDirectUrl: String? = null,
|
||||
@SerializedName("bio")
|
||||
val bio: String? = null,
|
||||
@SerializedName("trtcUserId")
|
||||
val trtcUserId: String? = null,
|
||||
@SerializedName("chatAIId")
|
||||
val chatAIId: String? = null,
|
||||
@SerializedName("aiAccount")
|
||||
val aiAccount: Boolean = true,
|
||||
@SerializedName("aiRoleAvatar")
|
||||
val aiRoleAvatar: String? = null,
|
||||
@SerializedName("aiRoleAvatarMedium")
|
||||
val aiRoleAvatarMedium: String? = null,
|
||||
@SerializedName("aiRoleAvatarLarge")
|
||||
val aiRoleAvatarLarge: String? = null,
|
||||
@SerializedName("aiRoleAvatarDirectUrl")
|
||||
val aiRoleAvatarDirectUrl: String? = null,
|
||||
@SerializedName("aiRoleAvatarMediumDirectUrl")
|
||||
val aiRoleAvatarMediumDirectUrl: String? = null,
|
||||
@SerializedName("aiRoleAvatarLargeDirectUrl")
|
||||
val aiRoleAvatarLargeDirectUrl: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 创建者资料
|
||||
*/
|
||||
data class CreatorProfile(
|
||||
@SerializedName("id")
|
||||
val id: Long,
|
||||
@SerializedName("username")
|
||||
val username: String? = null,
|
||||
@SerializedName("nickname")
|
||||
val nickname: String,
|
||||
@SerializedName("avatar")
|
||||
val avatar: String? = null,
|
||||
@SerializedName("avatarMedium")
|
||||
val avatarMedium: String? = null,
|
||||
@SerializedName("avatarLarge")
|
||||
val avatarLarge: String? = null,
|
||||
@SerializedName("avatarDirectUrl")
|
||||
val avatarDirectUrl: String? = null,
|
||||
@SerializedName("avatarMediumDirectUrl")
|
||||
val avatarMediumDirectUrl: String? = null,
|
||||
@SerializedName("avatarLargeDirectUrl")
|
||||
val avatarLargeDirectUrl: String? = null,
|
||||
@SerializedName("bio")
|
||||
val bio: String? = null,
|
||||
@SerializedName("trtcUserId")
|
||||
val trtcUserId: String? = null,
|
||||
@SerializedName("chatAIId")
|
||||
val chatAIId: String? = null,
|
||||
@SerializedName("aiAccount")
|
||||
val aiAccount: Boolean = false,
|
||||
@SerializedName("aiRoleAvatar")
|
||||
val aiRoleAvatar: String? = null,
|
||||
@SerializedName("aiRoleAvatarMedium")
|
||||
val aiRoleAvatarMedium: String? = null,
|
||||
@SerializedName("aiRoleAvatarLarge")
|
||||
val aiRoleAvatarLarge: String? = null,
|
||||
@SerializedName("aiRoleAvatarDirectUrl")
|
||||
val aiRoleAvatarDirectUrl: String? = null,
|
||||
@SerializedName("aiRoleAvatarMediumDirectUrl")
|
||||
val aiRoleAvatarMediumDirectUrl: String? = null,
|
||||
@SerializedName("aiRoleAvatarLargeDirectUrl")
|
||||
val aiRoleAvatarLargeDirectUrl: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Post类型推荐数据(复用Moment中的结构)
|
||||
* 支持 post_normal, post_video, post_news, post_music
|
||||
*/
|
||||
typealias PostRecommendationData = Moment
|
||||
|
||||
/**
|
||||
* Room类型推荐数据
|
||||
*/
|
||||
data class RoomRecommendationData(
|
||||
@SerializedName("id")
|
||||
val id: Long,
|
||||
@SerializedName("name")
|
||||
val name: String,
|
||||
@SerializedName("description")
|
||||
val description: String,
|
||||
@SerializedName("createdAt")
|
||||
val createdAt: String? = null,
|
||||
@SerializedName("updatedAt")
|
||||
val updatedAt: String? = null,
|
||||
@SerializedName("cover")
|
||||
val cover: String? = null,
|
||||
@SerializedName("coverDirectUrl")
|
||||
val coverDirectUrl: String? = null,
|
||||
@SerializedName("avatar")
|
||||
val avatar: String? = null,
|
||||
@SerializedName("avatarDirectUrl")
|
||||
val avatarDirectUrl: String? = null,
|
||||
@SerializedName("recommendBanner")
|
||||
val recommendBanner: String? = null,
|
||||
@SerializedName("trtcRoomId")
|
||||
val trtcRoomId: String? = null,
|
||||
@SerializedName("trtcType")
|
||||
val trtcType: String? = null,
|
||||
@SerializedName("isRecommended")
|
||||
val isRecommended: Boolean = false,
|
||||
@SerializedName("allowInHot")
|
||||
val allowInHot: Boolean = false,
|
||||
@SerializedName("language")
|
||||
val language: String? = null,
|
||||
@SerializedName("maxTotal")
|
||||
val maxTotal: Int? = null,
|
||||
@SerializedName("privateFeePaid")
|
||||
val privateFeePaid: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* 推荐Service接口
|
||||
*/
|
||||
interface RecommendationService {
|
||||
/**
|
||||
* 获取推荐列表
|
||||
* @param pool 推荐池名称(可选)
|
||||
* @param count 返回数量(可选,默认10,最大50)
|
||||
* @param lang 语言代码(可选)
|
||||
* @param promptReplaceTrans 是否用翻译覆盖原字段(可选,仅对prompt类型生效)
|
||||
* @return 推荐项列表
|
||||
*/
|
||||
suspend fun getRecommendations(
|
||||
pool: String? = null,
|
||||
count: Int? = null,
|
||||
lang: String? = null,
|
||||
promptReplaceTrans: Boolean? = null
|
||||
): List<RecommendationItem>
|
||||
}
|
||||
|
||||
/**
|
||||
* 推荐Service实现
|
||||
*/
|
||||
class RecommendationServiceImpl : RecommendationService {
|
||||
override suspend fun getRecommendations(
|
||||
pool: String?,
|
||||
count: Int?,
|
||||
lang: String?,
|
||||
promptReplaceTrans: Boolean?
|
||||
): List<RecommendationItem> {
|
||||
val resp = ApiClient.api.getRecommendations(
|
||||
pool = pool,
|
||||
count = count,
|
||||
lang = lang,
|
||||
promptReplaceTrans = promptReplaceTrans
|
||||
)
|
||||
if (resp.isSuccessful) {
|
||||
val body = resp.body()
|
||||
if (body != null && body.success) {
|
||||
return body.data
|
||||
}
|
||||
}
|
||||
throw ServiceException("Failed to get recommendations")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.aiosman.ravenow.data.Comment
|
||||
import com.aiosman.ravenow.data.DataContainer
|
||||
import com.aiosman.ravenow.data.ListContainer
|
||||
import com.aiosman.ravenow.data.Moment
|
||||
import com.aiosman.ravenow.data.RecommendationsResponse
|
||||
import com.aiosman.ravenow.data.Room
|
||||
import com.aiosman.ravenow.entity.ChatNotification
|
||||
import com.aiosman.ravenow.data.membership.MembershipConfigData
|
||||
@@ -1194,5 +1195,13 @@ interface RaveNowAPI {
|
||||
@Path("promptId") promptId: String
|
||||
): Response<DataContainer<PromptRuleQuota>>
|
||||
|
||||
@GET("recommendations")
|
||||
suspend fun getRecommendations(
|
||||
@Query("pool") pool: String? = null,
|
||||
@Query("count") count: Int? = null,
|
||||
@Query("lang") lang: String? = null,
|
||||
@Query("promptReplaceTrans") promptReplaceTrans: Boolean? = null
|
||||
): Response<RecommendationsResponse>
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -250,8 +250,26 @@ data class MomentImageEntity(
|
||||
val id: Long,
|
||||
// 图片URL
|
||||
val url: String,
|
||||
// 原始图片URL
|
||||
val originalUrl: String? = null,
|
||||
// 直接访问URL
|
||||
val directUrl: String? = null,
|
||||
// 缩略图URL
|
||||
val thumbnail: String,
|
||||
// 缩略图直接访问URL
|
||||
val thumbnailDirectUrl: String? = null,
|
||||
// 小尺寸图片URL
|
||||
val small: String? = null,
|
||||
// 小尺寸图片直接访问URL
|
||||
val smallDirectUrl: String? = null,
|
||||
// 中尺寸图片URL
|
||||
val medium: String? = null,
|
||||
// 中尺寸图片直接访问URL
|
||||
val mediumDirectUrl: String? = null,
|
||||
// 大尺寸图片URL
|
||||
val large: String? = null,
|
||||
// 大尺寸图片直接访问URL
|
||||
val largeDirectUrl: String? = null,
|
||||
// 图片BlurHash
|
||||
val blurHash: String? = null,
|
||||
// 宽度
|
||||
|
||||
@@ -48,6 +48,7 @@ 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.timeline.TimelineMomentsList
|
||||
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.news.NewsScreen
|
||||
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.recommend.RecommendScreen
|
||||
import com.aiosman.ravenow.ui.index.tabs.search.SearchViewModel
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -231,7 +232,7 @@ fun MomentsList() {
|
||||
when (it) {
|
||||
0 -> {
|
||||
// 推荐页面
|
||||
NewsScreen()
|
||||
RecommendScreen()
|
||||
}
|
||||
1 -> {
|
||||
// 短视频页面
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.recommend
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.aiosman.ravenow.entity.MomentEntity
|
||||
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.news.NewsItem
|
||||
|
||||
/**
|
||||
* 新闻推荐Item组件
|
||||
* 复用 NewsItem 组件
|
||||
* 注意:如果新闻没有图片,则不显示(返回空)
|
||||
*/
|
||||
@Composable
|
||||
fun NewsRecommendationItem(
|
||||
moment: MomentEntity,
|
||||
onCommentClick: () -> Unit = {},
|
||||
onReadFullClick: () -> Unit = {},
|
||||
onLikeClick: () -> Unit = {},
|
||||
onFavoriteClick: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// 如果新闻没有图片,则不显示
|
||||
if (moment.images.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
NewsItem(
|
||||
moment = moment,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
onCommentClick = onCommentClick,
|
||||
onReadFullClick = onReadFullClick,
|
||||
onLikeClick = onLikeClick,
|
||||
onFavoriteClick = onFavoriteClick
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.recommend
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
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.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.entity.MomentEntity
|
||||
import com.aiosman.ravenow.ui.comment.CommentModalContent
|
||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
|
||||
/**
|
||||
* 动态推荐Item组件(post_normal)
|
||||
* 参照短视频界面样式,但用图片替代视频播放器
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PostRecommendationItem(
|
||||
moment: MomentEntity,
|
||||
onLikeClick: ((MomentEntity) -> Unit)? = null,
|
||||
onCommentClick: ((MomentEntity) -> Unit)? = null,
|
||||
onFavoriteClick: ((MomentEntity) -> Unit)? = null,
|
||||
onShareClick: ((MomentEntity) -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var showCommentModal by remember { mutableStateOf(false) }
|
||||
var sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
|
||||
// 图片列表
|
||||
val images = moment.images
|
||||
val imageCount = images.size
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
) {
|
||||
// 图片显示区域(替代视频播放器)
|
||||
if (imageCount > 0) {
|
||||
// 只显示第一张图片,优先使用 thumbnailDirectUrl
|
||||
val imageUrl = images[0].thumbnailDirectUrl
|
||||
?: images[0].directUrl
|
||||
?: images[0].url
|
||||
CustomAsyncImage(
|
||||
context = context,
|
||||
imageUrl = imageUrl,
|
||||
contentDescription = "动态图片",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
blurHash = images[0].blurHash
|
||||
)
|
||||
} else {
|
||||
// 没有图片时显示默认图片
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.default_moment_img),
|
||||
contentDescription = "默认图片",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
|
||||
// 底部左侧:用户信息和文字内容
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.BottomStart
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, bottom = 16.dp)
|
||||
) {
|
||||
// 用户头像和昵称
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.2f))
|
||||
) {
|
||||
CustomAsyncImage(
|
||||
imageUrl = moment.avatar,
|
||||
contentDescription = "用户头像",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
defaultRes = R.drawable.default_avatar
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "@${moment.nickname}",
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
fontSize = 16.sp,
|
||||
color = Color.White,
|
||||
style = TextStyle(fontWeight = FontWeight.Bold)
|
||||
)
|
||||
}
|
||||
|
||||
// 文字内容
|
||||
if (moment.momentTextContent.isNotEmpty()) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.8f)
|
||||
.padding(top = 4.dp),
|
||||
text = moment.momentTextContent,
|
||||
fontSize = 16.sp,
|
||||
color = Color.White,
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 底部右侧:互动按钮
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.BottomEnd
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 72.dp, end = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// 用户头像
|
||||
UserAvatar(avatarUrl = moment.avatar)
|
||||
|
||||
// 点赞
|
||||
VideoBtn(
|
||||
icon = if (moment.liked) R.drawable.rider_pro_video_like else R.drawable.rider_pro_video_like,
|
||||
text = formatCount(moment.likeCount)
|
||||
) {
|
||||
onLikeClick?.invoke(moment)
|
||||
}
|
||||
|
||||
// 评论
|
||||
VideoBtn(
|
||||
icon = R.drawable.rider_pro_video_comment,
|
||||
text = formatCount(moment.commentCount)
|
||||
) {
|
||||
showCommentModal = true
|
||||
onCommentClick?.invoke(moment)
|
||||
}
|
||||
|
||||
// 收藏
|
||||
VideoBtn(
|
||||
icon = R.drawable.rider_pro_video_favor,
|
||||
text = formatCount(moment.favoriteCount)
|
||||
) {
|
||||
onFavoriteClick?.invoke(moment)
|
||||
}
|
||||
|
||||
// 分享
|
||||
VideoBtn(
|
||||
icon = R.drawable.rider_pro_video_share,
|
||||
text = formatCount(moment.shareCount)
|
||||
) {
|
||||
onShareClick?.invoke(moment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 评论弹窗
|
||||
if (showCommentModal) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showCommentModal = false },
|
||||
containerColor = Color.White,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
CommentModalContent(postId = moment.id) {
|
||||
// 评论添加后的回调
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserAvatar(avatarUrl: String? = null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 16.dp)
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.2f))
|
||||
) {
|
||||
if (avatarUrl != null && avatarUrl.isNotEmpty()) {
|
||||
CustomAsyncImage(
|
||||
imageUrl = avatarUrl,
|
||||
contentDescription = "用户头像",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
defaultRes = R.drawable.default_avatar
|
||||
)
|
||||
} else {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.default_avatar),
|
||||
contentDescription = "用户头像"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化数字显示
|
||||
private fun formatCount(count: Int): String {
|
||||
return when {
|
||||
count >= 1_000_000 -> String.format("%.1fM", count / 1_000_000.0)
|
||||
count >= 1_000 -> String.format("%.1fK", count / 1_000.0)
|
||||
else -> count.toString()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VideoBtn(
|
||||
icon: Int,
|
||||
text: String,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 16.dp)
|
||||
.noRippleClickable {
|
||||
onClick?.invoke()
|
||||
},
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Image(
|
||||
modifier = Modifier.size(36.dp),
|
||||
painter = painterResource(id = icon),
|
||||
contentDescription = ""
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 11.sp,
|
||||
color = Color.White,
|
||||
style = TextStyle(fontWeight = FontWeight.Bold)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.recommend
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.NavigationRoute
|
||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
|
||||
/**
|
||||
* Prompt推荐Item组件
|
||||
* 精美的AI Agent卡片样式
|
||||
*/
|
||||
@Composable
|
||||
fun PromptRecommendationItem(
|
||||
id: Long,
|
||||
title: String,
|
||||
desc: String,
|
||||
avatar: String?,
|
||||
openId: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val navController = LocalNavController.current
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
) {
|
||||
// 背景大图
|
||||
CustomAsyncImage(
|
||||
imageUrl = avatar,
|
||||
contentDescription = "AI Agent 头像",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
defaultRes = R.drawable.default_avatar
|
||||
)
|
||||
|
||||
// 渐变遮罩(底部深色)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.7f)
|
||||
),
|
||||
startY = 200f
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// 内容区域
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.BottomStart
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp, end = 24.dp, bottom = 40.dp)
|
||||
) {
|
||||
// AI Agent 标签
|
||||
Surface(
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = Color.White.copy(alpha = 0.2f)
|
||||
) {
|
||||
Text(
|
||||
text = "✨ AI Agent",
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
fontSize = 12.sp,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
|
||||
// 名称
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
// 描述
|
||||
Text(
|
||||
text = desc,
|
||||
fontSize = 16.sp,
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
lineHeight = 22.sp,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
// Start chatting 按钮
|
||||
Button(
|
||||
onClick = {
|
||||
// 导航到聊天页面
|
||||
navController.navigate("${NavigationRoute.Chat.route}/$openId")
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White,
|
||||
contentColor = Color.Black
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "💬 Start chatting",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.recommend
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.pager.VerticalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.GuestLoginCheckOut
|
||||
import com.aiosman.ravenow.GuestLoginCheckOutScene
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.entity.MomentEntity
|
||||
import com.aiosman.ravenow.ui.NavigationRoute
|
||||
import com.aiosman.ravenow.ui.composables.rememberDebouncer
|
||||
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.recommend.RecommendViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 推荐界面主组件
|
||||
* 使用 VerticalPager 实现上下滑动切换页面
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun RecommendScreen() {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val navController = LocalNavController.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val recommendationsList = RecommendViewModel.recommendations
|
||||
val loading = RecommendViewModel.isLoading
|
||||
val errorMessage = RecommendViewModel.error
|
||||
val hasMore = RecommendViewModel.hasNext
|
||||
|
||||
// 垂直翻页状态
|
||||
val pagerState = rememberPagerState(pageCount = { recommendationsList.size })
|
||||
|
||||
// 防抖器
|
||||
val likeDebouncer = rememberDebouncer()
|
||||
val favoriteDebouncer = rememberDebouncer()
|
||||
|
||||
// 初始化加载数据
|
||||
LaunchedEffect(Unit) {
|
||||
RecommendViewModel.refreshRecommendations()
|
||||
}
|
||||
|
||||
// 当翻页接近末尾时加载更多
|
||||
LaunchedEffect(pagerState.currentPage, recommendationsList.size) {
|
||||
if (recommendationsList.isNotEmpty() && pagerState.currentPage >= recommendationsList.size - 2 && hasMore) {
|
||||
RecommendViewModel.loadMore()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(AppColors.background)
|
||||
) {
|
||||
when {
|
||||
// 加载中
|
||||
loading && recommendationsList.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CircularProgressIndicator(color = AppColors.main)
|
||||
Text(
|
||||
text = "加载中...",
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
color = AppColors.text,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
errorMessage != null && recommendationsList.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "加载失败: $errorMessage",
|
||||
color = AppColors.error,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 空状态
|
||||
recommendationsList.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "暂无推荐内容",
|
||||
color = AppColors.text,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 显示推荐列表
|
||||
else -> {
|
||||
VerticalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) { page ->
|
||||
val item = recommendationsList.getOrNull(page) ?: return@VerticalPager
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when (item) {
|
||||
is RecommendationDisplayItem.Post -> {
|
||||
val moment = item.moment
|
||||
when {
|
||||
// 新闻类型
|
||||
moment.isNews -> {
|
||||
NewsRecommendationItem(
|
||||
moment = moment,
|
||||
onCommentClick = {
|
||||
// TODO: 打开评论弹窗
|
||||
},
|
||||
onReadFullClick = {
|
||||
// TODO: 打开全文弹窗
|
||||
},
|
||||
onLikeClick = {
|
||||
likeDebouncer {
|
||||
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) {
|
||||
navController.navigate(NavigationRoute.Login.route)
|
||||
} else {
|
||||
scope.launch {
|
||||
if (moment.liked) {
|
||||
RecommendViewModel.dislikeMoment(moment.id)
|
||||
} else {
|
||||
RecommendViewModel.likeMoment(moment.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onFavoriteClick = {
|
||||
favoriteDebouncer {
|
||||
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) {
|
||||
navController.navigate(NavigationRoute.Login.route)
|
||||
} else {
|
||||
scope.launch {
|
||||
if (moment.isFavorite) {
|
||||
RecommendViewModel.unfavoriteMoment(moment.id)
|
||||
} else {
|
||||
RecommendViewModel.favoriteMoment(moment.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 短视频类型
|
||||
!moment.videos.isNullOrEmpty() -> {
|
||||
VideoRecommendationItem(
|
||||
moment = moment,
|
||||
isVisible = pagerState.currentPage == page,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onLikeClick = { m ->
|
||||
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) {
|
||||
navController.navigate(NavigationRoute.Login.route)
|
||||
} else {
|
||||
scope.launch {
|
||||
if (m.liked) {
|
||||
RecommendViewModel.dislikeMoment(m.id)
|
||||
} else {
|
||||
RecommendViewModel.likeMoment(m.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onCommentClick = { m ->
|
||||
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.COMMENT_MOMENT)) {
|
||||
navController.navigate(NavigationRoute.Login.route)
|
||||
} else {
|
||||
scope.launch {
|
||||
RecommendViewModel.onAddComment(m.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
onFavoriteClick = { m ->
|
||||
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) {
|
||||
navController.navigate(NavigationRoute.Login.route)
|
||||
} else {
|
||||
scope.launch {
|
||||
if (m.isFavorite) {
|
||||
RecommendViewModel.unfavoriteMoment(m.id)
|
||||
} else {
|
||||
RecommendViewModel.favoriteMoment(m.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onShareClick = { _ ->
|
||||
// TODO: 实现分享功能
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 普通动态类型
|
||||
else -> {
|
||||
PostRecommendationItem(
|
||||
moment = moment,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onLikeClick = { m ->
|
||||
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) {
|
||||
navController.navigate(NavigationRoute.Login.route)
|
||||
} else {
|
||||
scope.launch {
|
||||
if (m.liked) {
|
||||
RecommendViewModel.dislikeMoment(m.id)
|
||||
} else {
|
||||
RecommendViewModel.likeMoment(m.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onCommentClick = { m ->
|
||||
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.COMMENT_MOMENT)) {
|
||||
navController.navigate(NavigationRoute.Login.route)
|
||||
} else {
|
||||
scope.launch {
|
||||
RecommendViewModel.onAddComment(m.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
onFavoriteClick = { m ->
|
||||
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) {
|
||||
navController.navigate(NavigationRoute.Login.route)
|
||||
} else {
|
||||
scope.launch {
|
||||
if (m.isFavorite) {
|
||||
RecommendViewModel.unfavoriteMoment(m.id)
|
||||
} else {
|
||||
RecommendViewModel.favoriteMoment(m.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onShareClick = { _ ->
|
||||
// TODO: 实现分享功能
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is RecommendationDisplayItem.Prompt -> {
|
||||
PromptRecommendationItem(
|
||||
id = item.id,
|
||||
title = item.title,
|
||||
desc = item.desc,
|
||||
avatar = item.avatar,
|
||||
openId = item.openId,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.recommend
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.aiosman.ravenow.data.MomentService
|
||||
import com.aiosman.ravenow.entity.MomentServiceImpl
|
||||
import com.aiosman.ravenow.data.PostRecommendationData
|
||||
import com.aiosman.ravenow.data.RecommendationItem
|
||||
import com.aiosman.ravenow.data.RecommendationService
|
||||
import com.aiosman.ravenow.data.RecommendationServiceImpl
|
||||
import com.aiosman.ravenow.data.ServiceException
|
||||
import com.aiosman.ravenow.entity.MomentEntity
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 推荐界面统一数据模型
|
||||
*/
|
||||
sealed class RecommendationDisplayItem {
|
||||
data class Post(val moment: MomentEntity) : RecommendationDisplayItem()
|
||||
data class Prompt(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val desc: String,
|
||||
val avatar: String?,
|
||||
val openId: String
|
||||
) : RecommendationDisplayItem()
|
||||
}
|
||||
|
||||
object RecommendViewModel : ViewModel() {
|
||||
private val recommendationService: RecommendationService = RecommendationServiceImpl()
|
||||
private val momentService: MomentService = MomentServiceImpl()
|
||||
|
||||
var recommendations by mutableStateOf<List<RecommendationDisplayItem>>(listOf())
|
||||
private set
|
||||
|
||||
var isLoading by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var error by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
var hasNext by mutableStateOf(true)
|
||||
private set
|
||||
|
||||
private var currentPage = 1
|
||||
private val pageSize = 20
|
||||
private var isFirstLoad = true
|
||||
|
||||
/**
|
||||
* 刷新推荐列表
|
||||
*/
|
||||
fun refreshRecommendations() {
|
||||
if (!isFirstLoad) {
|
||||
return
|
||||
}
|
||||
if (isLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
isFirstLoad = false
|
||||
currentPage = 1
|
||||
recommendations = listOf()
|
||||
hasNext = true
|
||||
|
||||
viewModelScope.launch {
|
||||
loadRecommendations()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载更多推荐
|
||||
*/
|
||||
fun loadMore() {
|
||||
if (isFirstLoad) {
|
||||
refreshRecommendations()
|
||||
return
|
||||
}
|
||||
if (!hasNext || isLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
currentPage++
|
||||
loadRecommendations(append = true)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadRecommendations(append: Boolean = false) {
|
||||
isLoading = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
Log.d("RecommendViewModel", "开始加载推荐数据,append=$append, pageSize=$pageSize")
|
||||
val items = recommendationService.getRecommendations(
|
||||
count = pageSize,
|
||||
lang = null,
|
||||
promptReplaceTrans = null
|
||||
)
|
||||
|
||||
Log.d("RecommendViewModel", "获取到推荐数据: ${items.size} 条")
|
||||
|
||||
// 过滤掉 post_music 和 room 类型
|
||||
val filteredItems = items.filter {
|
||||
it.type !in listOf("post_music", "room")
|
||||
}
|
||||
|
||||
Log.d("RecommendViewModel", "过滤后数据: ${filteredItems.size} 条")
|
||||
|
||||
val displayItems = filteredItems.mapNotNull { item ->
|
||||
try {
|
||||
when (item.type) {
|
||||
"post_normal", "post_video", "post_news" -> {
|
||||
val postData = item.toPostData()
|
||||
if (postData != null) {
|
||||
RecommendationDisplayItem.Post(postData.toMomentItem())
|
||||
} else {
|
||||
Log.w("RecommendViewModel", "无法转换 post 数据,type=${item.type}, id=${item.id}")
|
||||
null
|
||||
}
|
||||
}
|
||||
"prompt" -> {
|
||||
val promptData = item.toPromptData()
|
||||
if (promptData != null) {
|
||||
// 优先使用 aiUserProfile 的 aiRoleAvatarMediumDirectUrl
|
||||
val avatarUrl = promptData.aiUserProfile?.aiRoleAvatarMediumDirectUrl
|
||||
?: promptData.aiUserProfile?.aiRoleAvatarDirectUrl
|
||||
?: promptData.avatarDirectUrl
|
||||
?: promptData.avatar?.let { "${com.aiosman.ravenow.data.api.ApiClient.BASE_SERVER}$it" }
|
||||
RecommendationDisplayItem.Prompt(
|
||||
id = promptData.id,
|
||||
title = promptData.title,
|
||||
desc = promptData.desc,
|
||||
avatar = avatarUrl,
|
||||
openId = promptData.openId ?: ""
|
||||
)
|
||||
} else {
|
||||
Log.w("RecommendViewModel", "无法转换 prompt 数据,id=${item.id}")
|
||||
null
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Log.w("RecommendViewModel", "未知类型: ${item.type}, id=${item.id}")
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("RecommendViewModel", "转换推荐项失败,type=${item.type}, id=${item.id}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
Log.d("RecommendViewModel", "成功转换数据: ${displayItems.size} 条")
|
||||
|
||||
if (append) {
|
||||
recommendations = recommendations + displayItems
|
||||
} else {
|
||||
recommendations = displayItems
|
||||
}
|
||||
|
||||
hasNext = items.size >= pageSize
|
||||
Log.d("RecommendViewModel", "加载完成,当前总数: ${recommendations.size}, hasNext=$hasNext")
|
||||
|
||||
} catch (e: ServiceException) {
|
||||
val errorMsg = e.message ?: "加载推荐失败"
|
||||
error = errorMsg
|
||||
Log.e("RecommendViewModel", "ServiceException: $errorMsg", e)
|
||||
e.printStackTrace()
|
||||
if (!append) {
|
||||
isFirstLoad = true // 允许重试
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val errorMsg = e.message ?: "加载推荐时发生未知错误"
|
||||
error = errorMsg
|
||||
Log.e("RecommendViewModel", "Exception: $errorMsg", e)
|
||||
e.printStackTrace()
|
||||
if (!append) {
|
||||
isFirstLoad = true // 允许重试
|
||||
}
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 点赞动态
|
||||
*/
|
||||
suspend fun likeMoment(id: Int) {
|
||||
try {
|
||||
momentService.likeMoment(id)
|
||||
updateMomentLike(id, true)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消点赞动态
|
||||
*/
|
||||
suspend fun dislikeMoment(id: Int) {
|
||||
try {
|
||||
momentService.dislikeMoment(id)
|
||||
updateMomentLike(id, false)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收藏动态
|
||||
*/
|
||||
suspend fun favoriteMoment(id: Int) {
|
||||
try {
|
||||
momentService.favoriteMoment(id)
|
||||
updateMomentFavorite(id, true)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消收藏动态
|
||||
*/
|
||||
suspend fun unfavoriteMoment(id: Int) {
|
||||
try {
|
||||
momentService.unfavoriteMoment(id)
|
||||
updateMomentFavorite(id, false)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加评论
|
||||
*/
|
||||
fun onAddComment(id: Int) {
|
||||
updateMomentCommentCount(id, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新动态点赞状态
|
||||
*/
|
||||
private fun updateMomentLike(id: Int, isLike: Boolean) {
|
||||
recommendations = recommendations.map { item ->
|
||||
if (item is RecommendationDisplayItem.Post && item.moment.id == id) {
|
||||
val moment = item.moment
|
||||
RecommendationDisplayItem.Post(
|
||||
moment.copy(
|
||||
liked = isLike,
|
||||
likeCount = moment.likeCount + if (isLike) 1 else -1
|
||||
)
|
||||
)
|
||||
} else {
|
||||
item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新动态收藏状态
|
||||
*/
|
||||
private fun updateMomentFavorite(id: Int, isFavorite: Boolean) {
|
||||
recommendations = recommendations.map { item ->
|
||||
if (item is RecommendationDisplayItem.Post && item.moment.id == id) {
|
||||
val moment = item.moment
|
||||
RecommendationDisplayItem.Post(
|
||||
moment.copy(
|
||||
isFavorite = isFavorite,
|
||||
favoriteCount = moment.favoriteCount + if (isFavorite) 1 else -1
|
||||
)
|
||||
)
|
||||
} else {
|
||||
item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新动态评论数
|
||||
*/
|
||||
private fun updateMomentCommentCount(id: Int, delta: Int) {
|
||||
recommendations = recommendations.map { item ->
|
||||
if (item is RecommendationDisplayItem.Post && item.moment.id == id) {
|
||||
val moment = item.moment
|
||||
RecommendationDisplayItem.Post(
|
||||
moment.copy(
|
||||
commentCount = (moment.commentCount + delta).coerceAtLeast(0)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置状态
|
||||
*/
|
||||
fun reset() {
|
||||
recommendations = listOf()
|
||||
isLoading = false
|
||||
error = null
|
||||
hasNext = true
|
||||
currentPage = 1
|
||||
isFirstLoad = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.recommend
|
||||
|
||||
import android.net.Uri
|
||||
import android.view.Gravity
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.common.util.Util
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DefaultDataSourceFactory
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.entity.MomentEntity
|
||||
import com.aiosman.ravenow.ui.comment.CommentModalContent
|
||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
||||
/**
|
||||
* 短视频推荐Item组件
|
||||
* 不包含内部 Pager,由外层的 VerticalPager 处理滑动
|
||||
*/
|
||||
@OptIn(UnstableApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun VideoRecommendationItem(
|
||||
moment: MomentEntity,
|
||||
isVisible: Boolean = true, // 是否可见,用于控制播放/暂停
|
||||
onLikeClick: ((MomentEntity) -> Unit)? = null,
|
||||
onCommentClick: ((MomentEntity) -> Unit)? = null,
|
||||
onFavoriteClick: ((MomentEntity) -> Unit)? = null,
|
||||
onShareClick: ((MomentEntity) -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// 检查是否有视频
|
||||
val videoUrl = moment.videos?.firstOrNull()?.url
|
||||
if (videoUrl == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var showCommentModal by remember { mutableStateOf(false) }
|
||||
var sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
var pauseIconVisibleState by remember { mutableStateOf(false) }
|
||||
|
||||
val exoPlayer = remember(videoUrl) {
|
||||
ExoPlayer.Builder(context)
|
||||
.build()
|
||||
.apply {
|
||||
// 创建带有认证头的 HttpDataSource.Factory
|
||||
val httpDataSourceFactory = DefaultHttpDataSource.Factory()
|
||||
.setUserAgent(Util.getUserAgent(context, context.packageName))
|
||||
.setDefaultRequestProperties(
|
||||
mapOf(
|
||||
"Authorization" to "Bearer ${com.aiosman.ravenow.AppStore.token ?: ""}",
|
||||
"DEVICE-OS" to "Android"
|
||||
)
|
||||
)
|
||||
|
||||
// 创建 DataSource.Factory,使用自定义的 HttpDataSource.Factory
|
||||
val dataSourceFactory: DataSource.Factory = DefaultDataSourceFactory(
|
||||
context,
|
||||
httpDataSourceFactory
|
||||
)
|
||||
val source = ProgressiveMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(MediaItem.fromUri(Uri.parse(videoUrl)))
|
||||
|
||||
this.prepare(source)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据 isVisible 控制播放/暂停
|
||||
LaunchedEffect(isVisible) {
|
||||
if (isVisible) {
|
||||
exoPlayer.playWhenReady = true
|
||||
exoPlayer.play()
|
||||
} else {
|
||||
exoPlayer.pause()
|
||||
}
|
||||
}
|
||||
|
||||
exoPlayer.videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
|
||||
exoPlayer.repeatMode = Player.REPEAT_MODE_ONE
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
) {
|
||||
// 视频播放器
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
FrameLayout(ctx).apply {
|
||||
setBackgroundColor(Color.Black.toArgb())
|
||||
val view = PlayerView(ctx).apply {
|
||||
hideController()
|
||||
useController = false
|
||||
player = exoPlayer
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
Gravity.CENTER
|
||||
)
|
||||
}
|
||||
addView(view)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.noRippleClickable {
|
||||
pauseIconVisibleState = true
|
||||
exoPlayer.pause()
|
||||
scope.launch {
|
||||
delay(100)
|
||||
if (exoPlayer.isPlaying) {
|
||||
exoPlayer.pause()
|
||||
} else {
|
||||
pauseIconVisibleState = false
|
||||
exoPlayer.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (pauseIconVisibleState) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(80.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 底部右侧:互动按钮
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.BottomEnd
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 72.dp, end = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
UserAvatar(avatarUrl = moment.avatar)
|
||||
VideoBtn(
|
||||
icon = R.drawable.rider_pro_video_like,
|
||||
text = formatCount(moment.likeCount)
|
||||
) {
|
||||
onLikeClick?.invoke(moment)
|
||||
}
|
||||
VideoBtn(
|
||||
icon = R.drawable.rider_pro_video_comment,
|
||||
text = formatCount(moment.commentCount)
|
||||
) {
|
||||
showCommentModal = true
|
||||
onCommentClick?.invoke(moment)
|
||||
}
|
||||
VideoBtn(
|
||||
icon = R.drawable.rider_pro_video_favor,
|
||||
text = formatCount(moment.favoriteCount)
|
||||
) {
|
||||
onFavoriteClick?.invoke(moment)
|
||||
}
|
||||
VideoBtn(
|
||||
icon = R.drawable.rider_pro_video_share,
|
||||
text = formatCount(moment.shareCount)
|
||||
) {
|
||||
onShareClick?.invoke(moment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 底部左侧:用户信息和文字内容
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.BottomStart
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, bottom = 16.dp)
|
||||
) {
|
||||
if (moment.location.isNotEmpty() && moment.location != "Worldwide") {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 8.dp)
|
||||
.background(
|
||||
color = Color.Gray.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start
|
||||
) {
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.padding(end = 6.dp),
|
||||
painter = painterResource(id = R.drawable.rider_pro_video_location),
|
||||
contentDescription = ""
|
||||
)
|
||||
Text(
|
||||
text = moment.location,
|
||||
fontSize = 12.sp,
|
||||
color = Color.White,
|
||||
style = TextStyle(fontWeight = FontWeight.Bold)
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "@${moment.nickname}",
|
||||
fontSize = 16.sp,
|
||||
color = Color.White,
|
||||
style = TextStyle(fontWeight = FontWeight.Bold)
|
||||
)
|
||||
if (moment.momentTextContent.isNotEmpty()) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.8f)
|
||||
.padding(top = 4.dp),
|
||||
text = moment.momentTextContent,
|
||||
fontSize = 16.sp,
|
||||
color = Color.White,
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 评论弹窗
|
||||
if (showCommentModal) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showCommentModal = false },
|
||||
containerColor = Color.White,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
CommentModalContent(postId = moment.id) {
|
||||
// 评论添加后的回调
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 释放 ExoPlayer
|
||||
DisposableEffect(videoUrl) {
|
||||
onDispose {
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期管理
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> {
|
||||
exoPlayer.pause()
|
||||
}
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
if (isVisible) {
|
||||
exoPlayer.play()
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserAvatar(avatarUrl: String? = null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 16.dp)
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.2f))
|
||||
) {
|
||||
if (avatarUrl != null && avatarUrl.isNotEmpty()) {
|
||||
CustomAsyncImage(
|
||||
imageUrl = avatarUrl,
|
||||
contentDescription = "用户头像",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
defaultRes = R.drawable.default_avatar
|
||||
)
|
||||
} else {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.default_avatar),
|
||||
contentDescription = "用户头像"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化数字显示
|
||||
private fun formatCount(count: Int): String {
|
||||
return when {
|
||||
count >= 1_000_000 -> String.format("%.1fM", count / 1_000_000.0)
|
||||
count >= 1_000 -> String.format("%.1fK", count / 1_000.0)
|
||||
else -> count.toString()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VideoBtn(
|
||||
icon: Int,
|
||||
text: String,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 16.dp)
|
||||
.noRippleClickable {
|
||||
onClick?.invoke()
|
||||
},
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Image(
|
||||
modifier = Modifier.size(36.dp),
|
||||
painter = painterResource(id = icon),
|
||||
contentDescription = ""
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 11.sp,
|
||||
color = Color.White,
|
||||
style = TextStyle(fontWeight = FontWeight.Bold)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user