Merge pull request #63 from Kevinlinpr/atm2

Atm2
This commit is contained in:
2025-11-11 10:15:45 +08:00
committed by GitHub
24 changed files with 2413 additions and 159 deletions

View File

@@ -127,5 +127,11 @@ dependencies {
implementation (libs.eventbus)
implementation(libs.lottie)
// CameraX + ML Kit版本在 libs.versions.toml
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.mlkit.barcode.scanning)
}

View File

@@ -7,6 +7,8 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<application
android:name=".RaveNowApplication"

View File

@@ -12,6 +12,7 @@ import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.DictService
import com.aiosman.ravenow.data.DictServiceImpl
import com.aiosman.ravenow.data.PointService
import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.ui.favourite.FavouriteListViewModel
import com.aiosman.ravenow.ui.favourite.FavouriteNoticeViewModel
@@ -81,6 +82,14 @@ object AppState {
// 注册 JPush
Messaging.registerDevice(scope, context)
initChat(context)
// 设置当前用户并刷新积分信息(完成登录态初始化后)
PointService.setCurrentUser(UserId)
try {
PointService.refreshMyPointsBalance()
} catch (e: Exception) {
Log.e("AppState", "刷新积分失败: ${e.message}")
}
}
/**
@@ -228,6 +237,8 @@ object AppState {
AgentViewModel.ResetModel()
MineAgentViewModel.ResetModel()
UserId = null
// 清空积分全局状态,避免用户切换串号
PointService.clear()
// 清除游客状态
AppStore.isGuest = false

View File

@@ -37,6 +37,7 @@ import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.dialogs.CheckUpdateDialog
import com.aiosman.ravenow.ui.navigateToPost
import com.aiosman.ravenow.ui.post.NewPostViewModel
import com.aiosman.ravenow.ui.points.PointsBottomSheetHost
import com.google.firebase.Firebase
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.analytics
@@ -141,6 +142,8 @@ class MainActivity : ComponentActivity() {
LocalAppTheme provides AppState.appTheme
) {
CheckUpdateDialog()
// 全局挂载积分底部弹窗 Host
PointsBottomSheetHost()
Navigation(startDestination) { navController ->
// 处理带有 postId 的通知点击

View File

@@ -108,6 +108,15 @@ interface AgentService {
authorId: Int? = null
): ListContainer<AgentEntity>?
/**
* 根据标题关键字搜索智能体
*/
suspend fun searchAgentByTitle(
pageNumber: Int,
pageSize: Int = 20,
title: String
): ListContainer<AgentEntity>?
}

View File

@@ -0,0 +1,240 @@
package com.aiosman.ravenow.data
import com.aiosman.ravenow.AppStore
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.api.PointsBalance
import com.aiosman.ravenow.data.api.PointsChangeLog
import com.aiosman.ravenow.data.api.PointsChangeLogsResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
/**
* 积分服务
*
* 提供积分余额查询和积分变更日志查询功能
*/
object PointService {
// 全局可观察的积分余额(仅内存,不落盘)
private val _pointsBalance = MutableStateFlow<PointsBalance?>(null)
val pointsBalance: StateFlow<PointsBalance?> = _pointsBalance.asStateFlow()
// 当前已加载的用户ID用于处理用户切换
@Volatile
private var currentUserId: Int? = null
/**
* 设置当前用户ID当用户切换时会清空旧的积分数据以避免串号
*/
fun setCurrentUser(userId: Int?) {
if (currentUserId != userId) {
currentUserId = userId
_pointsBalance.value = null
}
}
/**
* 清空内存中的积分状态(用于登出或用户切换)
*/
fun clear() {
_pointsBalance.value = null
currentUserId = null
}
/**
* 刷新当前用户的积分余额(进入应用并完成登录态初始化后调用)
* - 若为游客或无 token则清空并返回
*/
suspend fun refreshMyPointsBalance(includeStatistics: Boolean = true) {
withContext(Dispatchers.IO) {
if (AppStore.isGuest || AppStore.token == null) {
clear()
return@withContext
}
val balance = getMyPointsBalance(includeStatistics)
_pointsBalance.value = balance
}
}
/**
* 获取当前用户积分余额
*
* @param includeStatistics 是否包含统计信息(累计获得和累计消费),默认 true
* @return 积分余额信息,包含当前余额和可选的统计数据
* @throws Exception 网络请求失败或服务器返回错误
*
* 示例:
* ```kotlin
* try {
* // 获取包含统计信息的积分余额
* val balance = PointService.getMyPointsBalance()
* println("当前余额: ${balance.balance}")
* println("累计获得: ${balance.totalEarned}")
* println("累计消费: ${balance.totalSpent}")
*
* // 仅获取当前余额
* val simpleBalance = PointService.getMyPointsBalance(includeStatistics = false)
* println("当前余额: ${simpleBalance.balance}")
* } catch (e: Exception) {
* println("获取积分余额失败: ${e.message}")
* }
* ```
*/
suspend fun getMyPointsBalance(includeStatistics: Boolean = true): PointsBalance {
return withContext(Dispatchers.IO) {
val response = ApiClient.api.getMyPointsBalance(includeStatistics)
if (response.isSuccessful) {
response.body()?.data ?: throw Exception("响应数据为空")
} else {
throw Exception("获取积分余额失败: ${response.code()} ${response.message()}")
}
}
}
/**
* 获取当前用户积分变更日志列表
*
* @param page 页码,默认 1
* @param pageSize 每页数量,默认 20
* @param changeType 变更类型筛选("add": 增加, "subtract": 减少, "adjust": 调整null 表示不筛选
* @param startTime 开始时间格式YYYY-MM-DDnull 表示不限制
* @param endTime 结束时间格式YYYY-MM-DDnull 表示不限制
* @return 积分变更日志列表响应,包含日志列表和分页信息
* @throws Exception 网络请求失败或服务器返回错误
*
* 示例:
* ```kotlin
* try {
* // 获取最近的积分变更日志
* val logs = PointService.getMyPointsChangeLogs(page = 1, pageSize = 20)
* println("总记录数: ${logs.total}")
* logs.list.forEach { log ->
* println("${log.createdAt}: ${log.changeType} ${log.amount} (${log.reason})")
* }
*
* // 筛选积分增加记录
* val earnLogs = PointService.getMyPointsChangeLogs(changeType = "add")
*
* // 查询指定时间范围的记录
* val rangeLogs = PointService.getMyPointsChangeLogs(
* startTime = "2024-01-01",
* endTime = "2024-01-31"
* )
* } catch (e: Exception) {
* println("获取积分变更日志失败: ${e.message}")
* }
* ```
*/
suspend fun getMyPointsChangeLogs(
page: Int = 1,
pageSize: Int = 20,
changeType: String? = null,
startTime: String? = null,
endTime: String? = null
): PointsChangeLogsResponse {
return withContext(Dispatchers.IO) {
val response = ApiClient.api.getMyPointsChangeLogs(
page = page,
pageSize = pageSize,
changeType = changeType,
startTime = startTime,
endTime = endTime
)
if (response.isSuccessful) {
response.body() ?: throw Exception("响应数据为空")
} else {
throw Exception("获取积分变更日志失败: ${response.code()} ${response.message()}")
}
}
}
/**
* 积分变更类型常量
*/
object ChangeType {
/** 积分增加 */
const val ADD = "add"
/** 积分减少 */
const val SUBTRACT = "subtract"
/** 积分调整 */
const val ADJUST = "adjust"
}
/**
* 积分变更原因常量
*/
object ChangeReason {
// 获得积分类型
/** 新用户注册奖励 */
const val EARN_REGISTER = "earn_register"
/** 每日签到奖励 */
const val EARN_DAILY = "earn_daily"
/** 任务完成奖励 */
const val EARN_TASK = "earn_task"
/** 邀请好友奖励 */
const val EARN_INVITE = "earn_invite"
/** 充值获得 */
const val EARN_RECHARGE = "earn_recharge"
// 消费积分类型
/** 创建群聊 */
const val SPEND_GROUP_CREATE = "spend_group_create"
/** 扩容群聊 */
const val SPEND_GROUP_EXPAND = "spend_group_expand"
/** Agent 私密模式 */
const val SPEND_AGENT_PRIVATE = "spend_agent_private"
/** Agent 记忆添加 */
const val SPEND_AGENT_MEMORY = "spend_agent_memory"
/** 房间记忆添加 */
const val SPEND_ROOM_MEMORY = "spend_room_memory"
/** 自定义聊天背景 */
const val SPEND_CHAT_BACKGROUND = "spend_chat_background"
/** 定时事件解锁 */
const val SPEND_SCHEDULE_EVENT = "spend_schedule_event"
}
/**
* 获取变更原因的中文描述
*
* @param reason 变更原因代码
* @return 中文描述
*/
fun getReasonDescription(reason: String): String {
return when (reason) {
ChangeReason.EARN_REGISTER -> "新用户注册奖励"
ChangeReason.EARN_DAILY -> "每日签到奖励"
ChangeReason.EARN_TASK -> "任务完成奖励"
ChangeReason.EARN_INVITE -> "邀请好友奖励"
ChangeReason.EARN_RECHARGE -> "充值获得"
ChangeReason.SPEND_GROUP_CREATE -> "创建群聊"
ChangeReason.SPEND_GROUP_EXPAND -> "扩容群聊"
ChangeReason.SPEND_AGENT_PRIVATE -> "Agent 私密模式"
ChangeReason.SPEND_AGENT_MEMORY -> "Agent 记忆添加"
ChangeReason.SPEND_ROOM_MEMORY -> "房间记忆添加"
ChangeReason.SPEND_CHAT_BACKGROUND -> "自定义聊天背景"
ChangeReason.SPEND_SCHEDULE_EVENT -> "定时事件解锁"
else -> reason // 未知原因,返回原始代码
}
}
/**
* 获取变更类型的中文描述
*
* @param changeType 变更类型代码
* @return 中文描述
*/
fun getChangeTypeDescription(changeType: String): String {
return when (changeType) {
ChangeType.ADD -> "增加"
ChangeType.SUBTRACT -> "减少"
ChangeType.ADJUST -> "调整"
else -> changeType
}
}
}

View File

@@ -6,7 +6,19 @@ import com.aiosman.ravenow.data.api.UpdateRoomRuleRequestBody
import com.aiosman.ravenow.data.api.RoomRuleQuota
import com.aiosman.ravenow.data.api.RoomRule
import com.aiosman.ravenow.data.api.RoomRuleCreator
import com.aiosman.ravenow.entity.AddAgentToRoomFailedItemEntity
import com.aiosman.ravenow.entity.AddAgentToRoomItemEntity
import com.aiosman.ravenow.entity.AddAgentToRoomResultEntity
import com.aiosman.ravenow.entity.AddUserToRoomFailedItemEntity
import com.aiosman.ravenow.entity.AddUserToRoomItemEntity
import com.aiosman.ravenow.entity.AddUserToRoomResultEntity
import com.aiosman.ravenow.entity.CreatorEntity
import com.aiosman.ravenow.entity.RemoveAgentFromRoomFailedItemEntity
import com.aiosman.ravenow.entity.RemoveAgentFromRoomItemEntity
import com.aiosman.ravenow.entity.RemoveAgentFromRoomResultEntity
import com.aiosman.ravenow.entity.RemoveUserFromRoomFailedItemEntity
import com.aiosman.ravenow.entity.RemoveUserFromRoomItemEntity
import com.aiosman.ravenow.entity.RemoveUserFromRoomResultEntity
import com.aiosman.ravenow.entity.RoomEntity
import com.aiosman.ravenow.entity.RoomRuleEntity
import com.aiosman.ravenow.entity.RoomRuleCreatorEntity
@@ -173,6 +185,68 @@ interface RoomService {
roomId: Int? = null,
trtcId: String? = null
): RoomRuleQuotaEntity
// ========== Room Member Management ==========
/**
* 添加用户到房间
*
* @param roomId 房间ID与 trtcId 二选一
* @param trtcId TRTC群组ID与 roomId 二选一
* @param openIds 要添加的用户OpenID列表
* @return 添加结果实体
* @throws ServiceException 添加失败时抛出异常
*/
suspend fun addUserToRoom(
roomId: Int? = null,
trtcId: String? = null,
openIds: List<String>
): AddUserToRoomResultEntity
/**
* 添加智能体到房间
*
* @param roomId 房间ID与 trtcId 二选一
* @param trtcId TRTC群组ID与 roomId 二选一
* @param agentOpenIds 要添加的智能体OpenID列表
* @return 添加结果实体
* @throws ServiceException 添加失败时抛出异常
*/
suspend fun addAgentToRoom(
roomId: Int? = null,
trtcId: String? = null,
agentOpenIds: List<String>
): AddAgentToRoomResultEntity
/**
* 从房间移除智能体
*
* @param roomId 房间ID与 trtcId 二选一
* @param trtcId TRTC群组ID与 roomId 二选一
* @param agentOpenIds 要移除的智能体OpenID列表
* @return 移除结果实体
* @throws ServiceException 移除失败时抛出异常
*/
suspend fun removeAgentFromRoom(
roomId: Int? = null,
trtcId: String? = null,
agentOpenIds: List<String>
): RemoveAgentFromRoomResultEntity
/**
* 从房间移除用户
*
* @param roomId 房间ID与 trtcId 二选一
* @param trtcId TRTC群组ID与 roomId 二选一
* @param userIds 要移除的用户ID列表OpenID
* @return 移除结果实体
* @throws ServiceException 移除失败时抛出异常
*/
suspend fun removeUserFromRoom(
roomId: Int? = null,
trtcId: String? = null,
userIds: List<String>
): RemoveUserFromRoomResultEntity
}
/**
@@ -253,6 +327,78 @@ class RoomServiceImpl : RoomService {
return data.toRoomRuleQuotaEntity()
}
override suspend fun addUserToRoom(
roomId: Int?,
trtcId: String?,
openIds: List<String>
): AddUserToRoomResultEntity {
val resp = ApiClient.api.addUserToRoom(
com.aiosman.ravenow.data.api.AddUserToRoomRequestBody(
roomId = roomId,
trtcId = trtcId,
openIds = openIds
)
)
val body = resp.body() ?: throw ServiceException("添加用户到房间失败")
val data = body.data ?: throw ServiceException("添加用户响应数据为空")
return data.result.toAddUserToRoomResultEntity()
}
override suspend fun addAgentToRoom(
roomId: Int?,
trtcId: String?,
agentOpenIds: List<String>
): AddAgentToRoomResultEntity {
val resp = ApiClient.api.addAgentToRoom(
com.aiosman.ravenow.data.api.AddAgentToRoomRequestBody(
roomId = roomId,
trtcId = trtcId,
agentOpenIds = agentOpenIds
)
)
val body = resp.body() ?: throw ServiceException("添加智能体到房间失败")
val data = body.data ?: throw ServiceException("添加智能体响应数据为空")
return data.result.toAddAgentToRoomResultEntity()
}
override suspend fun removeAgentFromRoom(
roomId: Int?,
trtcId: String?,
agentOpenIds: List<String>
): RemoveAgentFromRoomResultEntity {
val resp = ApiClient.api.removeAgentFromRoom(
com.aiosman.ravenow.data.api.RemoveAgentFromRoomRequestBody(
roomId = roomId,
trtcId = trtcId,
agentOpenIds = agentOpenIds
)
)
val body = resp.body() ?: throw ServiceException("从房间移除智能体失败")
val data = body.data ?: throw ServiceException("移除智能体响应数据为空")
return data.toRemoveAgentFromRoomResultEntity()
}
override suspend fun removeUserFromRoom(
roomId: Int?,
trtcId: String?,
userIds: List<String>
): RemoveUserFromRoomResultEntity {
val resp = ApiClient.api.removeUserFromRoom(
com.aiosman.ravenow.data.api.RemoveUserFromRoomRequestBody(
roomId = roomId,
trtcId = trtcId,
userIds = userIds
)
)
val body = resp.body() ?: throw ServiceException("从房间移除用户失败")
val data = body.data ?: throw ServiceException("移除用户响应数据为空")
return data.toRemoveUserFromRoomResultEntity()
}
}
/**
@@ -295,6 +441,128 @@ fun RoomRuleQuota.toRoomRuleQuotaEntity(): RoomRuleQuotaEntity {
)
}
// ========== Room Member Management 扩展函数 ==========
/**
* AddUserToRoomResult 扩展函数,转换为 AddUserToRoomResultEntity
*/
fun com.aiosman.ravenow.data.api.AddUserToRoomResult.toAddUserToRoomResultEntity(): AddUserToRoomResultEntity {
return AddUserToRoomResultEntity(
totalCount = totalCount,
successCount = successCount,
failedCount = failedCount,
skippedCount = skippedCount,
successItems = successItems.map { it.toAddUserToRoomItemEntity() },
failedItems = failedItems.map { it.toAddUserToRoomFailedItemEntity() },
skippedItems = skippedItems.map { it.toAddUserToRoomItemEntity() }
)
}
fun com.aiosman.ravenow.data.api.AddUserToRoomItem.toAddUserToRoomItemEntity(): AddUserToRoomItemEntity {
return AddUserToRoomItemEntity(
userId = userId,
type = type
)
}
fun com.aiosman.ravenow.data.api.AddUserToRoomFailedItem.toAddUserToRoomFailedItemEntity(): AddUserToRoomFailedItemEntity {
return AddUserToRoomFailedItemEntity(
userId = userId,
type = type,
error = error
)
}
/**
* AddAgentToRoomResult 扩展函数,转换为 AddAgentToRoomResultEntity
*/
fun com.aiosman.ravenow.data.api.AddAgentToRoomResult.toAddAgentToRoomResultEntity(): AddAgentToRoomResultEntity {
return AddAgentToRoomResultEntity(
totalCount = totalCount,
successCount = successCount,
failedCount = failedCount,
skippedCount = skippedCount,
successItems = successItems.map { it.toAddAgentToRoomItemEntity() },
failedItems = failedItems.map { it.toAddAgentToRoomFailedItemEntity() },
skippedItems = skippedItems.map { it.toAddAgentToRoomItemEntity() }
)
}
fun com.aiosman.ravenow.data.api.AddAgentToRoomItem.toAddAgentToRoomItemEntity(): AddAgentToRoomItemEntity {
return AddAgentToRoomItemEntity(
agentOpenId = agentOpenId,
type = type
)
}
fun com.aiosman.ravenow.data.api.AddAgentToRoomFailedItem.toAddAgentToRoomFailedItemEntity(): AddAgentToRoomFailedItemEntity {
return AddAgentToRoomFailedItemEntity(
agentOpenId = agentOpenId,
type = type,
error = error
)
}
/**
* RemoveAgentFromRoomResult 扩展函数,转换为 RemoveAgentFromRoomResultEntity
*/
fun com.aiosman.ravenow.data.api.RemoveAgentFromRoomResult.toRemoveAgentFromRoomResultEntity(): RemoveAgentFromRoomResultEntity {
return RemoveAgentFromRoomResultEntity(
totalCount = totalCount,
successCount = successCount,
failedCount = failedCount,
skippedCount = skippedCount,
successItems = successItems.map { it.toRemoveAgentFromRoomItemEntity() },
failedItems = failedItems.map { it.toRemoveAgentFromRoomFailedItemEntity() },
skippedItems = skippedItems.map { it.toRemoveAgentFromRoomItemEntity() }
)
}
fun com.aiosman.ravenow.data.api.RemoveAgentFromRoomItem.toRemoveAgentFromRoomItemEntity(): RemoveAgentFromRoomItemEntity {
return RemoveAgentFromRoomItemEntity(
agentOpenId = agentOpenId,
type = type
)
}
fun com.aiosman.ravenow.data.api.RemoveAgentFromRoomFailedItem.toRemoveAgentFromRoomFailedItemEntity(): RemoveAgentFromRoomFailedItemEntity {
return RemoveAgentFromRoomFailedItemEntity(
agentOpenId = agentOpenId,
type = type,
error = error
)
}
/**
* RemoveUserFromRoomResult 扩展函数,转换为 RemoveUserFromRoomResultEntity
*/
fun com.aiosman.ravenow.data.api.RemoveUserFromRoomResult.toRemoveUserFromRoomResultEntity(): RemoveUserFromRoomResultEntity {
return RemoveUserFromRoomResultEntity(
totalCount = totalCount,
successCount = successCount,
failedCount = failedCount,
skippedCount = skippedCount,
successItems = successItems.map { it.toRemoveUserFromRoomItemEntity() },
failedItems = failedItems.map { it.toRemoveUserFromRoomFailedItemEntity() },
skippedItems = skippedItems.map { it.toRemoveUserFromRoomItemEntity() }
)
}
fun com.aiosman.ravenow.data.api.RemoveUserFromRoomItem.toRemoveUserFromRoomItemEntity(): RemoveUserFromRoomItemEntity {
return RemoveUserFromRoomItemEntity(
userId = userId,
type = type
)
}
fun com.aiosman.ravenow.data.api.RemoveUserFromRoomFailedItem.toRemoveUserFromRoomFailedItemEntity(): RemoveUserFromRoomFailedItemEntity {
return RemoveUserFromRoomFailedItemEntity(
userId = userId,
type = type,
error = error
)
}

View File

@@ -274,6 +274,260 @@ data class RemoveAccountRequestBody(
val password: String,
)
// ========== Room Member Management 相关数据类 ==========
/**
* 添加用户到房间请求体
* @param roomId 房间ID与trtcId互斥二者必须提供其一
* @param trtcId TRTC群组ID与roomId互斥二者必须提供其一
* @param openIds 要添加的用户OpenID列表
*/
data class AddUserToRoomRequestBody(
@SerializedName("roomId")
val roomId: Int? = null,
@SerializedName("trtcId")
val trtcId: String? = null,
@SerializedName("openIds")
val openIds: List<String>
)
/**
* 添加智能体到房间请求体
* @param roomId 房间ID与trtcId互斥二者必须提供其一
* @param trtcId TRTC群组ID与roomId互斥二者必须提供其一
* @param agentOpenIds 要添加的智能体OpenID列表
*/
data class AddAgentToRoomRequestBody(
@SerializedName("roomId")
val roomId: Int? = null,
@SerializedName("trtcId")
val trtcId: String? = null,
@SerializedName("agentOpenIds")
val agentOpenIds: List<String>
)
/**
* 从房间移除智能体请求体
* @param roomId 房间ID与trtcId互斥二者必须提供其一
* @param trtcId TRTC群组ID与roomId互斥二者必须提供其一
* @param agentOpenIds 要移除的智能体OpenID列表
*/
data class RemoveAgentFromRoomRequestBody(
@SerializedName("roomId")
val roomId: Int? = null,
@SerializedName("trtcId")
val trtcId: String? = null,
@SerializedName("agentOpenIds")
val agentOpenIds: List<String>
)
/**
* 从房间移除用户请求体
* @param roomId 房间ID与trtcId互斥二者必须提供其一
* @param trtcId TRTC群组ID与roomId互斥二者必须提供其一
* @param userIds 要移除的用户ID列表OpenID
*/
data class RemoveUserFromRoomRequestBody(
@SerializedName("roomId")
val roomId: Int? = null,
@SerializedName("trtcId")
val trtcId: String? = null,
@SerializedName("userIds")
val userIds: List<String>
)
/**
* 添加用户成功项目
*/
data class AddUserToRoomItem(
@SerializedName("userId")
val userId: String,
@SerializedName("type")
val type: String
)
/**
* 添加用户失败项目
*/
data class AddUserToRoomFailedItem(
@SerializedName("userId")
val userId: String,
@SerializedName("type")
val type: String,
@SerializedName("error")
val error: String
)
/**
* 添加用户到房间的结果
*/
data class AddUserToRoomResult(
@SerializedName("totalCount")
val totalCount: Int,
@SerializedName("successCount")
val successCount: Int,
@SerializedName("failedCount")
val failedCount: Int,
@SerializedName("skippedCount")
val skippedCount: Int,
@SerializedName("successItems")
val successItems: List<AddUserToRoomItem>,
@SerializedName("failedItems")
val failedItems: List<AddUserToRoomFailedItem>,
@SerializedName("skippedItems")
val skippedItems: List<AddUserToRoomItem>
)
/**
* 添加用户到房间响应
*/
data class AddUserToRoomResponse(
@SerializedName("message")
val message: String,
@SerializedName("operationType")
val operationType: String,
@SerializedName("result")
val result: AddUserToRoomResult
)
/**
* 添加智能体成功项目
*/
data class AddAgentToRoomItem(
@SerializedName("agentOpenId")
val agentOpenId: String,
@SerializedName("type")
val type: String
)
/**
* 添加智能体失败项目
*/
data class AddAgentToRoomFailedItem(
@SerializedName("agentOpenId")
val agentOpenId: String,
@SerializedName("type")
val type: String,
@SerializedName("error")
val error: String
)
/**
* 添加智能体到房间的结果
*/
data class AddAgentToRoomResult(
@SerializedName("totalCount")
val totalCount: Int,
@SerializedName("successCount")
val successCount: Int,
@SerializedName("failedCount")
val failedCount: Int,
@SerializedName("skippedCount")
val skippedCount: Int,
@SerializedName("successItems")
val successItems: List<AddAgentToRoomItem>,
@SerializedName("failedItems")
val failedItems: List<AddAgentToRoomFailedItem>,
@SerializedName("skippedItems")
val skippedItems: List<AddAgentToRoomItem>
)
/**
* 添加智能体到房间响应
*/
data class AddAgentToRoomResponse(
@SerializedName("message")
val message: String,
@SerializedName("operationType")
val operationType: String,
@SerializedName("result")
val result: AddAgentToRoomResult
)
/**
* 移除智能体成功项目
*/
data class RemoveAgentFromRoomItem(
@SerializedName("agentOpenId")
val agentOpenId: String,
@SerializedName("type")
val type: String
)
/**
* 移除智能体失败项目
*/
data class RemoveAgentFromRoomFailedItem(
@SerializedName("agentOpenId")
val agentOpenId: String,
@SerializedName("type")
val type: String,
@SerializedName("error")
val error: String
)
/**
* 从房间移除智能体的结果
*/
data class RemoveAgentFromRoomResult(
@SerializedName("totalCount")
val totalCount: Int,
@SerializedName("successCount")
val successCount: Int,
@SerializedName("failedCount")
val failedCount: Int,
@SerializedName("skippedCount")
val skippedCount: Int,
@SerializedName("successItems")
val successItems: List<RemoveAgentFromRoomItem>,
@SerializedName("failedItems")
val failedItems: List<RemoveAgentFromRoomFailedItem>,
@SerializedName("skippedItems")
val skippedItems: List<RemoveAgentFromRoomItem>
)
/**
* 移除用户成功项目
*/
data class RemoveUserFromRoomItem(
@SerializedName("userId")
val userId: String,
@SerializedName("type")
val type: String
)
/**
* 移除用户失败项目
*/
data class RemoveUserFromRoomFailedItem(
@SerializedName("userId")
val userId: String,
@SerializedName("type")
val type: String,
@SerializedName("error")
val error: String
)
/**
* 从房间移除用户的结果
*/
data class RemoveUserFromRoomResult(
@SerializedName("totalCount")
val totalCount: Int,
@SerializedName("successCount")
val successCount: Int,
@SerializedName("failedCount")
val failedCount: Int,
@SerializedName("skippedCount")
val skippedCount: Int,
@SerializedName("successItems")
val successItems: List<RemoveUserFromRoomItem>,
@SerializedName("failedItems")
val failedItems: List<RemoveUserFromRoomFailedItem>,
@SerializedName("skippedItems")
val skippedItems: List<RemoveUserFromRoomItem>
)
// API 错误响应(用于加入房间等接口的错误处理)
data class ApiErrorResponse(
@SerializedName("err")
@@ -762,6 +1016,71 @@ data class InsufficientBalanceError(
val traceId: String?
)
// ========== Points 相关数据类 ==========
/**
* 积分余额信息
* @param balance 当前积分余额
* @param totalEarned 累计获得积分(可选)
* @param totalSpent 累计消费积分(可选)
*/
data class PointsBalance(
@SerializedName("balance")
val balance: Int,
@SerializedName("totalEarned")
val totalEarned: Int? = null,
@SerializedName("totalSpent")
val totalSpent: Int? = null
)
/**
* 积分变更日志
* @param id 日志记录ID
* @param changeType 变更类型add: 增加, subtract: 减少, adjust: 调整)
* @param before 变更前余额
* @param after 变更后余额
* @param amount 本次变更数量(增加为正数,减少为负数)
* @param reason 变更原因代码
* @param createdAt 创建时间格式YYYY-MM-DD HH:mm:ss
*/
data class PointsChangeLog(
@SerializedName("id")
val id: Int,
@SerializedName("changeType")
val changeType: String,
@SerializedName("before")
val before: Int,
@SerializedName("after")
val after: Int,
@SerializedName("amount")
val amount: Int,
@SerializedName("reason")
val reason: String,
@SerializedName("createdAt")
val createdAt: String
)
/**
* 积分变更日志列表响应
* @param success 请求是否成功
* @param list 积分变更日志列表
* @param total 总记录数
* @param page 当前页码
* @param pageSize 每页数量
*/
data class PointsChangeLogsResponse(
@SerializedName("success")
val success: Boolean,
@SerializedName("list")
val list: List<PointsChangeLog>,
@SerializedName("total")
val total: Int,
@SerializedName("page")
val page: Int,
@SerializedName("pageSize")
val pageSize: Int
)
interface RaveNowAPI {
@GET("membership/config")
@retrofit2.http.Headers("X-Requires-Auth: true")
@@ -1605,5 +1924,209 @@ interface RaveNowAPI {
@Query("promptReplaceTrans") promptReplaceTrans: Boolean? = null
): Response<RecommendationsResponse>
// ========== Points API ==========
/**
* 获取我的积分余额
*
* 功能说明:
* - 获取当前登录用户的积分余额
* - 可选返回累计获得和累计消费统计信息
*
* @param includeStatistics 是否包含统计信息(累计获得和累计消费),默认 true
*
* @return 返回积分余额和统计信息
*
* 响应数据说明:
* - balance: 当前积分余额
* - totalEarned: 累计获得积分(仅当 includeStatistics 为 true 时返回)
* - totalSpent: 累计消费积分(仅当 includeStatistics 为 true 时返回)
*
* 示例:
* ```kotlin
* // 获取包含统计信息的积分余额
* val response1 = api.getMyPointsBalance()
*
* // 仅获取当前余额
* val response2 = api.getMyPointsBalance(includeStatistics = false)
* ```
*/
@GET("account/my/points")
suspend fun getMyPointsBalance(
@Query("includeStatistics") includeStatistics: Boolean? = null
): Response<DataContainer<PointsBalance>>
/**
* 获取我的积分变更日志
*
* 功能说明:
* - 获取当前登录用户的积分变更日志列表
* - 支持分页、时间范围筛选和变更类型筛选
*
* @param page 页码,默认 1
* @param pageSize 每页数量,默认 20
* @param changeType 变更类型筛选add: 增加, subtract: 减少, adjust: 调整)
* @param startTime 开始时间格式YYYY-MM-DD
* @param endTime 结束时间格式YYYY-MM-DD
*
* @return 返回分页的积分变更日志列表
*
* 响应数据说明:
* - list: 积分变更日志列表
* - total: 总记录数
* - page: 当前页码
* - pageSize: 每页数量
*
* 示例:
* ```kotlin
* // 获取最近的积分变更日志
* val response1 = api.getMyPointsChangeLogs(page = 1, pageSize = 20)
*
* // 筛选积分增加记录
* val response2 = api.getMyPointsChangeLogs(changeType = "add")
*
* // 查询指定时间范围的记录
* val response3 = api.getMyPointsChangeLogs(
* startTime = "2024-01-01",
* endTime = "2024-01-31"
* )
* ```
*/
@GET("account/my/points/logs")
suspend fun getMyPointsChangeLogs(
@Query("page") page: Int = 1,
@Query("pageSize") pageSize: Int = 20,
@Query("changeType") changeType: String? = null,
@Query("startTime") startTime: String? = null,
@Query("endTime") endTime: String? = null
): Response<PointsChangeLogsResponse>
// ========== Room Member Management API ==========
/**
* 添加用户到房间
*
* 功能说明:
* - 向房间批量添加用户
* - 支持通过房间ID或TRTC群组ID添加
* - 只有房间创建者可以使用此接口
* - 添加数量受房间容量限制
* - 成功添加后会自动扣除房间创建者的积分用于扩容
*
* @param body 添加用户请求体
* - roomId: 房间ID与 trtcId 二选一)
* - trtcId: TRTC群组ID与 roomId 二选一)
* - openIds: 要添加的用户OpenID列表
*
* @return 成功时返回操作结果,包含成功、失败、跳过的用户列表
*
* 示例:
* ```kotlin
* val request = AddUserToRoomRequestBody(
* roomId = 123,
* openIds = listOf("user_openid_1", "user_openid_2")
* )
* val response = api.addUserToRoom(request)
* ```
*/
@POST("outside/rooms/add-user")
suspend fun addUserToRoom(
@Body body: AddUserToRoomRequestBody
): Response<DataContainer<AddUserToRoomResponse>>
/**
* 添加智能体到房间
*
* 功能说明:
* - 向房间批量添加智能体Agent
* - 支持通过房间ID或TRTC群组ID添加
* - 房间创建者可以使用此接口
* - 当容量不足时会自动扩容并扣除房间创建者的积分
* - 如果积分不足以支付扩容费用,将返回错误
*
* @param body 添加智能体请求体
* - roomId: 房间ID与 trtcId 二选一)
* - trtcId: TRTC群组ID与 roomId 二选一)
* - agentOpenIds: 要添加的智能体OpenID列表
*
* @return 成功时返回操作结果,包含成功、失败、跳过的智能体列表
*
* 示例:
* ```kotlin
* val request = AddAgentToRoomRequestBody(
* roomId = 123,
* agentOpenIds = listOf("agent_openid_1", "agent_openid_2")
* )
* val response = api.addAgentToRoom(request)
* ```
*/
@POST("outside/rooms/add-agent")
suspend fun addAgentToRoom(
@Body body: AddAgentToRoomRequestBody
): Response<DataContainer<AddAgentToRoomResponse>>
/**
* 从房间移除智能体
*
* 功能说明:
* - 从房间批量移除智能体Agent
* - 支持通过房间ID或TRTC群组ID操作
* - 只有房间创建者可以使用此接口
* - 单聊房间不支持移除智能体
* - 移除操作会同步到OpenIM系统
*
* @param body 移除智能体请求体
* - roomId: 房间ID与 trtcId 二选一)
* - trtcId: TRTC群组ID与 roomId 二选一)
* - agentOpenIds: 要移除的智能体OpenID列表
*
* @return 成功时返回操作结果,包含成功、失败、跳过的智能体列表
*
* 示例:
* ```kotlin
* val request = RemoveAgentFromRoomRequestBody(
* roomId = 123,
* agentOpenIds = listOf("agent_openid_1", "agent_openid_2")
* )
* val response = api.removeAgentFromRoom(request)
* ```
*/
@POST("outside/rooms/remove-agent")
suspend fun removeAgentFromRoom(
@Body body: RemoveAgentFromRoomRequestBody
): Response<DataContainer<RemoveAgentFromRoomResult>>
/**
* 从房间移除用户
*
* 功能说明:
* - 从房间批量移除用户
* - 支持通过房间ID或TRTC群组ID操作
* - 只有房间创建者可以使用此接口
* - 单聊房间不支持移除用户
* - 群主不能移除自己
* - 移除操作会同步到OpenIM系统
*
* @param body 移除用户请求体
* - roomId: 房间ID与 trtcId 二选一)
* - trtcId: TRTC群组ID与 roomId 二选一)
* - userIds: 要移除的用户ID列表OpenID
*
* @return 成功时返回操作结果,包含成功、失败、跳过的用户列表
*
* 示例:
* ```kotlin
* val request = RemoveUserFromRoomRequestBody(
* roomId = 123,
* userIds = listOf("user_openid_1", "user_openid_2")
* )
* val response = api.removeUserFromRoom(request)
* ```
*/
@POST("outside/rooms/remove-user")
suspend fun removeUserFromRoom(
@Body body: RemoveUserFromRoomRequestBody
): Response<DataContainer<RemoveUserFromRoomResult>>
}

View File

@@ -90,6 +90,35 @@ class AgentPagingSource(
}
/**
* 智能体搜索分页加载器(按标题关键字)
*/
class AgentSearchPagingSource(
private val agentRemoteDataSource: AgentRemoteDataSource,
private val keyword: String,
) : PagingSource<Int, AgentEntity>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, AgentEntity> {
return try {
val currentPage = params.key ?: 1
val agents = agentRemoteDataSource.searchAgentByTitle(
pageNumber = currentPage,
title = keyword
)
LoadResult.Page(
data = agents?.list ?: listOf(),
prevKey = if (currentPage == 1) null else currentPage - 1,
nextKey = if (agents?.list?.isNotEmpty() == true) currentPage + 1 else null
)
} catch (exception: IOException) {
LoadResult.Error(exception)
}
}
override fun getRefreshKey(state: PagingState<Int, AgentEntity>): Int? {
return state.anchorPosition
}
}
class AgentRemoteDataSource(
private val agentService: AgentService,
@@ -103,6 +132,16 @@ class AgentRemoteDataSource(
authorId = authorId
)
}
suspend fun searchAgentByTitle(
pageNumber: Int,
title: String
): ListContainer<AgentEntity>? {
return agentService.searchAgentByTitle(
pageNumber = pageNumber,
title = title
)
}
}
class AgentServiceImpl() : AgentService {
@@ -118,6 +157,17 @@ class AgentServiceImpl() : AgentService {
authorId = authorId
)
}
override suspend fun searchAgentByTitle(
pageNumber: Int,
pageSize: Int,
title: String
): ListContainer<AgentEntity>? {
return agentBackend.searchAgentByTitle(
pageNumber = pageNumber,
title = title
)
}
}
class AgentBackend {
@@ -175,6 +225,27 @@ class AgentBackend {
)
}
}
suspend fun searchAgentByTitle(
pageNumber: Int,
title: String
): ListContainer<AgentEntity>? {
val resp = ApiClient.api.getAgent(
page = pageNumber,
pageSize = DataBatchSize,
withWorkflow = 1,
title = title
)
val body = resp.body() ?: return null
val dataContainer = body as DataContainer<ListContainer<Agent>>
val listContainer = dataContainer.data
return ListContainer(
total = listContainer.total,
page = pageNumber,
pageSize = DataBatchSize,
list = listContainer.list.map { it.toAgentEntity() }
)
}
}
data class AgentEntity(

View File

@@ -86,6 +86,128 @@ data class RoomRuleQuotaEntity(
val usagePercent: Double
)
// ========== Room Member Management 实体类 ==========
/**
* 添加用户成功项目
*/
data class AddUserToRoomItemEntity(
val userId: String,
val type: String
)
/**
* 添加用户失败项目
*/
data class AddUserToRoomFailedItemEntity(
val userId: String,
val type: String,
val error: String
)
/**
* 添加用户到房间的结果
*/
data class AddUserToRoomResultEntity(
val totalCount: Int,
val successCount: Int,
val failedCount: Int,
val skippedCount: Int,
val successItems: List<AddUserToRoomItemEntity>,
val failedItems: List<AddUserToRoomFailedItemEntity>,
val skippedItems: List<AddUserToRoomItemEntity>
)
/**
* 添加智能体成功项目
*/
data class AddAgentToRoomItemEntity(
val agentOpenId: String,
val type: String
)
/**
* 添加智能体失败项目
*/
data class AddAgentToRoomFailedItemEntity(
val agentOpenId: String,
val type: String,
val error: String
)
/**
* 添加智能体到房间的结果
*/
data class AddAgentToRoomResultEntity(
val totalCount: Int,
val successCount: Int,
val failedCount: Int,
val skippedCount: Int,
val successItems: List<AddAgentToRoomItemEntity>,
val failedItems: List<AddAgentToRoomFailedItemEntity>,
val skippedItems: List<AddAgentToRoomItemEntity>
)
/**
* 移除智能体成功项目
*/
data class RemoveAgentFromRoomItemEntity(
val agentOpenId: String,
val type: String
)
/**
* 移除智能体失败项目
*/
data class RemoveAgentFromRoomFailedItemEntity(
val agentOpenId: String,
val type: String,
val error: String
)
/**
* 从房间移除智能体的结果
*/
data class RemoveAgentFromRoomResultEntity(
val totalCount: Int,
val successCount: Int,
val failedCount: Int,
val skippedCount: Int,
val successItems: List<RemoveAgentFromRoomItemEntity>,
val failedItems: List<RemoveAgentFromRoomFailedItemEntity>,
val skippedItems: List<RemoveAgentFromRoomItemEntity>
)
/**
* 移除用户成功项目
*/
data class RemoveUserFromRoomItemEntity(
val userId: String,
val type: String
)
/**
* 移除用户失败项目
*/
data class RemoveUserFromRoomFailedItemEntity(
val userId: String,
val type: String,
val error: String
)
/**
* 从房间移除用户的结果
*/
data class RemoveUserFromRoomResultEntity(
val totalCount: Int,
val successCount: Int,
val failedCount: Int,
val skippedCount: Int,
val successItems: List<RemoveUserFromRoomItemEntity>,
val failedItems: List<RemoveUserFromRoomFailedItemEntity>,
val skippedItems: List<RemoveUserFromRoomItemEntity>
)
class RoomLoader : DataLoader<AgentEntity,AgentLoaderExtraArgs>() {
override suspend fun fetchData(
page: Int,

View File

@@ -78,6 +78,7 @@ import com.aiosman.ravenow.ui.post.PostScreen
import com.aiosman.ravenow.ui.profile.AccountProfileV2
import com.aiosman.ravenow.ui.index.tabs.profile.vip.VipSelPage
import com.aiosman.ravenow.ui.notification.NotificationScreen
import com.aiosman.ravenow.ui.scan.ScanQrScreen
sealed class NavigationRoute(
val route: String,
@@ -130,6 +131,7 @@ sealed class NavigationRoute(
data object NotificationScreen : NavigationRoute("NotificationScreen")
data object MbtiSelect : NavigationRoute("MbtiSelect")
data object ZodiacSelect : NavigationRoute("ZodiacSelect")
data object ScanQr : NavigationRoute("ScanQr")
}
@@ -453,6 +455,9 @@ fun NavigationController(
SearchScreen()
}
}
composable(route = NavigationRoute.ScanQr.route) {
ScanQrScreen()
}
composable(
route = NavigationRoute.FollowerList.route,
arguments = listOf(navArgument("id") { type = NavType.IntType })

View File

@@ -586,7 +586,11 @@ fun SideMenuContent(
.align(Alignment.TopEnd)
.offset(x = (-112).dp, y = 88.dp)
.noRippleClickable {
// TODO: 实现扫一扫功能
// 扫一扫功能:跳转到扫码页面
coroutineScope.launch {
onClose()
navController.navigate(NavigationRoute.ScanQr.route)
}
},
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically

View File

@@ -88,6 +88,10 @@ import androidx.compose.ui.window.DialogProperties
import androidx.compose.foundation.lazy.grid.items as gridItems
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Brush
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.ui.platform.LocalConfiguration
// 检测是否接近列表底部的扩展函数
fun LazyListState.isScrolledToEnd(buffer: Int = 3): Boolean {
@@ -273,6 +277,7 @@ fun Agent() {
}
}
if (viewModel.chatRooms.isNotEmpty()) {
// 热门聊天室
stickyHeader(key = "hot_rooms_header") {
Row(
@@ -318,6 +323,7 @@ fun Agent() {
}
}
}
}
item { Spacer(modifier = Modifier.height(20.dp)) }
@@ -581,72 +587,146 @@ fun AgentCardSquare(
@Composable
fun AgentViewPagerSection(agentItems: List<AgentItem>,viewModel: AgentViewModel) {
val AppColors = LocalAppTheme.current
if (agentItems.isEmpty()) return
// 每页显示5个agent
val itemsPerPage = 5
val totalPages = (agentItems.size + itemsPerPage - 1) / itemsPerPage
if (totalPages > 0) {
val pagerState = rememberPagerState(pageCount = { totalPages })
val pagerState = rememberPagerState(pageCount = { agentItems.size })
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val cardAspect = 1133.5f / 846.4f
// 外层 LazyColumn 左右各 8dp + Pager contentPadding 左右各 20dp
val horizontalPaddings = 56.dp
val pagerHeight = (screenWidth - horizontalPaddings) * cardAspect
Column {
// Agent内容
Box(
modifier = Modifier
.height(310.dp)
.height(pagerHeight)
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp),
pageSpacing = 0.dp
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 20.dp),
pageSpacing = 12.dp
) { page ->
// 计算当前页面的偏移量
// 缩放效果
val pageOffset = (
(pagerState.currentPage - page) + pagerState
.currentPageOffsetFraction
).coerceIn(-1f, 1f)
val scale = 1f - (0.06f * kotlin.math.abs(pageOffset))
// 根据偏移量计算缩放比例
val scale = 1f - (0.1f * kotlin.math.abs(pageOffset))
AgentPage(
AgentLargeCard(
agentItem = agentItems[page],
viewModel = viewModel,
agentItems = agentItems.drop(page * itemsPerPage).take(itemsPerPage),
page = page,
navController = LocalNavController.current,
modifier = Modifier
.height(310.dp)
.graphicsLayer {
scaleX = scale
scaleY = scale
},
navController = LocalNavController.current,
}
)
}
}
}
}
@SuppressLint("SuspiciousIndentation")
@Composable
fun AgentLargeCard(
agentItem: AgentItem,
viewModel: AgentViewModel,
navController: NavHostController,
modifier: Modifier = Modifier
) {
val AppColors = LocalAppTheme.current
var lastClickTime by remember { mutableStateOf(0L) }
Box(
modifier = modifier
.fillMaxWidth()
.aspectRatio(846.4f / 1133.5f)
.clip(RoundedCornerShape(24.dp))
.noRippleClickable {
if (DebounceUtils.simpleDebounceClick(lastClickTime, 500L) {
viewModel.goToProfile(agentItem.openId, navController)
}) {
lastClickTime = System.currentTimeMillis()
}
}
) {
// 背景大图
CustomAsyncImage(
imageUrl = agentItem.avatar,
contentDescription = agentItem.title,
modifier = Modifier.fillMaxSize(),
contentScale = androidx.compose.ui.layout.ContentScale.Crop
)
// 底部渐变与文字
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.background(
Brush.verticalGradient(
0f to Color.Transparent,
1f to Color(0xB2000000)
)
)
.padding(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 56.dp) // 为底部聊天按钮预留空间
) {
androidx.compose.material3.Text(
text = agentItem.title,
color = Color.White,
fontSize = 20.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.W700,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
androidx.compose.material3.Text(
text = agentItem.desc,
color = Color.White.copy(alpha = 0.92f),
fontSize = 14.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
// 指示器
Row(
modifier = Modifier
.fillMaxWidth()
.height(30.dp)
.padding(top = 12.dp),
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Center
) {
repeat(totalPages) { index ->
// 底部居中 Chat 按钮
Box(
modifier = Modifier
.padding(horizontal = 4.dp)
.size(3.dp)
.background(
color = if (pagerState.currentPage == index) AppColors.text else AppColors.secondaryText.copy(
alpha = 0.3f
),
shape = androidx.compose.foundation.shape.CircleShape
)
)
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp)
.widthIn(min = 180.dp)
.fillMaxWidth(0.65f)
.height(44.dp)
.background(AppColors.text, RoundedCornerShape(22.dp))
.noRippleClickable {
if (DebounceUtils.simpleDebounceClick(lastClickTime, 500L) {
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.CHAT_WITH_AGENT)) {
navController.navigate(NavigationRoute.Login.route)
} else {
viewModel.createSingleChat(agentItem.openId)
viewModel.goToChatAi(agentItem.openId, navController)
}
}) {
lastClickTime = System.currentTimeMillis()
}
},
contentAlignment = Alignment.Center
) {
androidx.compose.material3.Text(
text = stringResource(R.string.chat),
color = AppColors.background,
fontSize = 16.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.W600
)
}
}
}

View File

@@ -28,6 +28,7 @@ import com.aiosman.ravenow.event.MomentAddEvent
import com.aiosman.ravenow.event.MomentFavouriteChangeEvent
import com.aiosman.ravenow.event.MomentLikeChangeEvent
import com.aiosman.ravenow.event.MomentRemoveEvent
import com.aiosman.ravenow.data.PointService
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
@@ -76,6 +77,14 @@ object MyProfileViewModel : ViewModel() {
}
firstLoad = false
loadUserProfile()
// 刷新积分(仅非游客)
if (!AppStore.isGuest) {
try {
PointService.refreshMyPointsBalance()
} catch (e: Exception) {
Log.e("MyProfileViewModel", "refresh points error: ", e)
}
}
refreshing = false
// 游客模式下不加载个人动态和智能体

View File

@@ -62,6 +62,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -74,6 +75,7 @@ import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.MainActivity
import com.aiosman.ravenow.R
import com.aiosman.ravenow.data.PointService
import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.entity.AgentEntity
import com.aiosman.ravenow.entity.MomentEntity
@@ -108,6 +110,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.graphics.Brush
import java.text.NumberFormat
import java.util.Locale
import com.aiosman.ravenow.ui.points.PointsBottomSheet
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -165,6 +168,7 @@ fun ProfileV3(
val listState = rememberLazyListState()
val gridState = rememberLazyGridState()
val scrollState = rememberScrollState()
val pointsBalanceState = PointService.pointsBalance.collectAsState(initial = null)
// 计算导航栏背景透明度根据滚动位置从0到1
val toolbarBackgroundAlpha by remember {
@@ -506,6 +510,9 @@ fun ProfileV3(
navController = navController,
backgroundAlpha = toolbarBackgroundAlpha,
interactionCount = moments.sumOf { it.likeCount }, // 计算总点赞数作为互动数据
onPointsClick = {
if (isSelf) com.aiosman.ravenow.ui.points.PointsSheetManager.open()
},
onMenuClick = {
showOtherUserMenu = true
},
@@ -580,6 +587,9 @@ fun ProfileV3(
}
}
}
// 积分底部弹窗
// 全局积分弹窗由 MainActivity 中的 PointsBottomSheetHost 承载
}
}
}
@@ -598,11 +608,14 @@ fun TopNavigationBar(
navController: androidx.navigation.NavController,
backgroundAlpha: Float,
interactionCount: Int = 0,
onPointsClick: (() -> Unit)? = null,
onMenuClick: () -> Unit = {},
onShareClick: () -> Unit = {}
) {
val appColors = LocalAppTheme.current
val numberFormat = remember { NumberFormat.getNumberInstance(Locale.getDefault()) }
// 仅本人主页显示积分:收集全局积分
val pointsBalanceState = if (isSelf) PointService.pointsBalance.collectAsState(initial = null) else null
// 根据背景透明度和暗色模式决定图标颜色
// 暗色模式下:图标始终为白色
@@ -704,7 +717,10 @@ fun TopNavigationBar(
color = cardBorderColor, // 根据背景透明度改变边框颜色
shape = RoundedCornerShape(16.dp)
)
.padding(horizontal = 8.dp),
.padding(horizontal = 8.dp)
.let {
if (isSelf) it.noRippleClickable { onPointsClick?.invoke() } else it
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
@@ -716,7 +732,11 @@ fun TopNavigationBar(
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = numberFormat.format(interactionCount),
text = if (isSelf) {
pointsBalanceState?.value?.balance?.let { numberFormat.format(it) } ?: "--"
} else {
numberFormat.format(interactionCount)
},
fontSize = 14.sp,
fontWeight = FontWeight.W500,
color = if (AppState.darkMode) Color.White else Color.Black, // 暗色模式下为白色,亮色模式下为黑色

View File

@@ -1,6 +1,7 @@
package com.aiosman.ravenow.ui.index.tabs.search
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -10,6 +11,8 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -32,14 +35,13 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.Tab
import androidx.compose.material.TabRow
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@@ -85,10 +87,8 @@ fun SearchScreen() {
val AppColors = LocalAppTheme.current
val context = LocalContext.current
val model = SearchViewModel
val categories = listOf(context.getString(R.string.moment), context.getString(R.string.users))
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState(pageCount = { categories.size })
val selectedTabIndex = remember { derivedStateOf { pagerState.currentPage } }
val pagerState = rememberPagerState(pageCount = { 3 })
val keyboardController = LocalSoftwareKeyboardController.current
val systemUiController = rememberSystemUiController()
val statusBarPaddingValues = WindowInsets.systemBars.asPaddingValues()
@@ -100,11 +100,19 @@ fun SearchScreen() {
systemUiController.setStatusBarColor(Color.Transparent, darkIcons = !AppState.darkMode)
}
LaunchedEffect(Unit) {
model.ensureInit(context)
if (model.requestFocus) {
focusRequester.requestFocus()
model.requestFocus = false
}
}
DisposableEffect(Unit) {
onDispose {
// 离开页面时重置搜索状态与文本
model.searchText = ""
model.ResetModel()
}
}
Column(
modifier = Modifier
@@ -130,7 +138,7 @@ fun SearchScreen() {
.weight(1f),
text = model.searchText,
onTextChange = {
model.searchText = it
model.onTextChanged(it)
},
onSearch = {
model.search()
@@ -144,13 +152,33 @@ fun SearchScreen() {
stringResource(R.string.cancel),
fontSize = 16.sp,
modifier = Modifier.noRippleClickable {
// 退出时也重置,确保返回后显示历史而不是上次结果
model.searchText = ""
model.ResetModel()
navController.navigateUp()
},
color = AppColors.text
)
}
// 历史搜索(当不展示结果时)
if (!model.showResult) {
HistorySection(
onClear = { model.clearHistory() },
onClick = { term ->
coroutineScope.launch {
keyboardController?.hide()
model.onTextChanged(term)
pagerState.scrollToPage(0)
model.search()
}
},
onRemove = { term -> model.removeHistoryItem(term) }
)
}
// 添加user、dynamic和ai标签页
if (model.showResult) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -188,31 +216,17 @@ fun SearchScreen() {
text = stringResource(R.string.chat_ai),
isSelected = pagerState.currentPage == 2,
onClick = {
// TODO: 实现点击逻辑
coroutineScope.launch {
pagerState.animateScrollToPage(2)
}
}
)
}
}
}
}
if (model.showResult) {
TabRow(
selectedTabIndex = selectedTabIndex.value,
backgroundColor = AppColors.background,
contentColor = AppColors.text,
) {
categories.forEachIndexed { index, category ->
Tab(
selected = selectedTabIndex.value == index,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
},
text = { Text(category, color = AppColors.text) }
)
}
}
SearchPager(
pagerState = pagerState
)
@@ -220,6 +234,67 @@ fun SearchScreen() {
}
}
@OptIn(ExperimentalLayoutApi::class, ExperimentalFoundationApi::class)
@Composable
fun HistorySection(
onClear: () -> Unit,
onClick: (String) -> Unit,
onRemove: (String) -> Unit
) {
val AppColors = LocalAppTheme.current
val model = SearchViewModel
val items = model.historyFlow.collectAsState(initial = emptyList()).value
if (items.isEmpty()) return
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "历史搜索",
color = AppColors.text,
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = "清空",
color = AppColors.secondaryText,
fontSize = 14.sp,
modifier = Modifier.noRippleClickable { onClear() }
)
}
Spacer(modifier = Modifier.height(12.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items.forEach { term ->
Box(
modifier = Modifier
.background(AppColors.inputBackground, RoundedCornerShape(16.dp))
.combinedClickable(
onClick = { onClick(term) },
onLongClick = { onRemove(term) }
)
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Text(
text = term,
color = AppColors.text,
fontSize = 14.sp
)
}
}
}
}
}
@Composable
fun SearchInput(
modifier: Modifier = Modifier,
@@ -300,6 +375,7 @@ fun SearchPager(
when (page) {
0 -> MomentResultTab()
1 -> UserResultTab()
2 -> AiResultTab()
}
}
}
@@ -566,6 +642,118 @@ fun UserItem(
}
}
@Composable
fun AiResultTab() {
val AppColors = LocalAppTheme.current
val context = LocalContext.current
val model = SearchViewModel
val agents = model.agentsFlow.collectAsLazyPagingItems()
Box(
modifier = Modifier
.fillMaxSize()
.background(AppColors.background)
) {
if (agents.itemCount == 0 && model.showResult) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
val isNetworkAvailable = NetworkUtils.isNetworkAvailable(context)
if (isNetworkAvailable) {
androidx.compose.foundation.Image(
painter = painterResource(
id = if (AppState.darkMode) R.mipmap.syss_yh_qs_as_img
else R.mipmap.invalid_name_1
),
contentDescription = "No Result",
modifier = Modifier.size(140.dp)
)
Text(
text = "咦,什么都没找到...",
color = LocalAppTheme.current.text,
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "换个关键词试试吧,也许会有新发现!",
color = LocalAppTheme.current.secondaryText,
fontSize = 14.sp,
fontWeight = FontWeight.W400
)
} else {
androidx.compose.foundation.Image(
painter = painterResource(id = R.mipmap.invalid_name_10),
contentDescription = "network error",
modifier = Modifier.size(140.dp)
)
Text(
text = stringResource(R.string.friend_chat_no_network_title),
color = LocalAppTheme.current.text,
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.friend_chat_no_network_subtitle),
color = LocalAppTheme.current.secondaryText,
fontSize = 14.sp,
fontWeight = FontWeight.W400
)
Spacer(modifier = Modifier.size(16.dp))
ReloadButton(
onClick = {
SearchViewModel.ResetModel()
SearchViewModel.search()
}
)
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(agents.itemCount) { idx ->
val agent = agents[idx] ?: return@items
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
CustomAsyncImage(
context,
imageUrl = agent.avatar,
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(24.dp)),
contentDescription = null
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = agent.title,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = AppColors.text
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = agent.desc,
fontSize = 14.sp,
color = AppColors.secondaryText,
maxLines = 1
)
}
}
}
}
}
}
}
@Composable
fun ReloadButton(
onClick: () -> Unit
) {

View File

@@ -1,5 +1,6 @@
package com.aiosman.ravenow.ui.index.tabs.search
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -18,6 +19,11 @@ import com.aiosman.ravenow.entity.MomentEntity
import com.aiosman.ravenow.entity.MomentPagingSource
import com.aiosman.ravenow.entity.MomentRemoteDataSource
import com.aiosman.ravenow.entity.MomentServiceImpl
import com.aiosman.ravenow.entity.AgentEntity
import com.aiosman.ravenow.entity.AgentRemoteDataSource
import com.aiosman.ravenow.entity.AgentSearchPagingSource
import com.aiosman.ravenow.entity.AgentServiceImpl
import com.aiosman.ravenow.utils.SearchHistoryStore
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
@@ -32,12 +38,37 @@ object SearchViewModel : ViewModel() {
private val userService = UserServiceImpl()
private val _usersFlow = MutableStateFlow<PagingData<AccountProfileEntity>>(PagingData.empty())
val usersFlow = _usersFlow.asStateFlow()
private val _agentsFlow = MutableStateFlow<PagingData<AgentEntity>>(PagingData.empty())
val agentsFlow = _agentsFlow.asStateFlow()
private lateinit var historyStore: SearchHistoryStore
private val _historyFlow = MutableStateFlow<List<String>>(emptyList())
val historyFlow = _historyFlow.asStateFlow()
var showResult by mutableStateOf(false)
var requestFocus by mutableStateOf(false)
fun ensureInit(context: Context) {
if (!::historyStore.isInitialized) {
historyStore = SearchHistoryStore(context.applicationContext)
_historyFlow.value = historyStore.getHistory()
}
}
fun onTextChanged(newText: String) {
searchText = newText
if (newText.isBlank()) {
showResult = false
}
}
fun search() {
if (searchText.isEmpty()) {
return
}
// 记录历史
if (::historyStore.isInitialized) {
historyStore.add(searchText)
_historyFlow.value = historyStore.getHistory()
}
viewModelScope.launch {
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
@@ -64,9 +95,34 @@ object SearchViewModel : ViewModel() {
_usersFlow.value = it
}
}
viewModelScope.launch {
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
AgentSearchPagingSource(
AgentRemoteDataSource(AgentServiceImpl()),
keyword = searchText
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_agentsFlow.value = it
}
}
showResult = true
}
fun removeHistoryItem(term: String) {
if (!::historyStore.isInitialized) return
historyStore.remove(term)
_historyFlow.value = historyStore.getHistory()
}
fun clearHistory() {
if (!::historyStore.isInitialized) return
historyStore.clear()
_historyFlow.value = emptyList()
}
suspend fun followUser(id:Int){
userService.followUser(id.toString())
val currentPagingData = _usersFlow.value
@@ -96,6 +152,7 @@ object SearchViewModel : ViewModel() {
fun ResetModel(){
_momentsFlow.value = PagingData.empty()
_usersFlow.value = PagingData.empty()
_agentsFlow.value = PagingData.empty()
showResult = false
}

View File

@@ -0,0 +1,301 @@
package com.aiosman.ravenow.ui.points
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.material3.Button
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.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.ui.composables.TabItem
import com.aiosman.ravenow.ui.composables.TabSpacer
import com.aiosman.ravenow.data.PointService
import com.aiosman.ravenow.data.api.PointsChangeLog
import java.text.NumberFormat
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PointsBottomSheet(
onClose: () -> Unit,
onRecharge: () -> Unit
){
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val AppColors = LocalAppTheme.current
val numberFormat = remember { NumberFormat.getNumberInstance(Locale.getDefault()) }
val balanceState = PointsViewModel.balance.collectAsState(initial = null)
var tab by remember { mutableStateOf(0) } // 0: history, 1: how to earn
LaunchedEffect(Unit) {
PointsViewModel.initLoad()
}
ModalBottomSheet(
onDismissRequest = onClose,
sheetState = sheetState,
containerColor = AppColors.background
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.98f)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
// 头部
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = "My Pai Coin", color = AppColors.text, fontSize = 20.sp, fontWeight = FontWeight.Bold)
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(Color(0xFFF1E9FF))
.clickable { onRecharge() }
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Text(text = "Recharge", color = Color(0xFF6B46C1), fontSize = 14.sp, fontWeight = FontWeight.W600,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.padding(0.dp)
)
}
}
Spacer(Modifier.height(12.dp))
// 余额卡片
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(AppColors.nonActive)
.padding(16.dp)
) {
Text("Current Balance", color = AppColors.secondaryText, fontSize = 14.sp)
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(id = R.mipmap.paip_coin_img),
contentDescription = "coin",
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
)
Spacer(Modifier.size(8.dp))
Text(
text = balanceState.value?.balance?.let { numberFormat.format(it) } ?: "--",
color = AppColors.text,
fontSize = 36.sp,
fontWeight = FontWeight.Bold
)
}
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f)) {
Text(text = numberFormat.format(balanceState.value?.totalEarned ?: 0), color = AppColors.text, fontSize = 18.sp, fontWeight = FontWeight.W700)
Text(text = "Total Earned", color = AppColors.secondaryText, fontSize = 12.sp)
}
Box(
modifier = Modifier
.height(22.dp)
.background(AppColors.divider)
.width(1.dp)
)
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f)) {
Text(text = numberFormat.format(balanceState.value?.totalSpent ?: 0), color = AppColors.text, fontSize = 18.sp, fontWeight = FontWeight.W700)
Text(text = "Total Spent", color = AppColors.secondaryText, fontSize = 12.sp)
}
}
}
Spacer(Modifier.height(12.dp))
// 分段切换
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
TabItem(
text = "Transaction History",
isSelected = tab == 0,
onClick = { tab = 0 },
modifier = Modifier.weight(1f)
)
TabSpacer()
TabItem(
text = "How to Earn",
isSelected = tab == 1,
onClick = { tab = 1 },
modifier = Modifier.weight(1f)
)
}
Spacer(Modifier.height(8.dp))
if (tab == 0) {
PointsHistoryList(
items = PointsViewModel.logs,
onLoadMore = { PointsViewModel.loadMore() },
hasNext = PointsViewModel.hasNext
)
} else {
HowToEarnList()
}
Spacer(Modifier.height(24.dp))
}
}
}
@Composable
private fun SegmentItem(selected: Boolean, text: String, onClick: () -> Unit) {
val AppColors = LocalAppTheme.current
Box(
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(if (selected) AppColors.background else Color.Transparent)
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Text(text = text, color = if (selected) AppColors.text else AppColors.secondaryText, fontSize = 14.sp, fontWeight = FontWeight.W600)
}
}
@Composable
private fun PointsHistoryList(
items: List<PointsChangeLog>,
onLoadMore: () -> Unit,
hasNext: Boolean
) {
val AppColors = LocalAppTheme.current
val numberFormat = remember { NumberFormat.getNumberInstance(Locale.getDefault()) }
LazyColumn {
items(items) { item ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(id = R.mipmap.paip_coin_img),
contentDescription = "reason",
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(12.dp))
)
Spacer(Modifier.size(12.dp))
Column {
Text(text = PointService.getReasonDescription(item.reason ?: "" ), color = AppColors.text, fontSize = 16.sp, fontWeight = FontWeight.W600)
Spacer(Modifier.height(4.dp))
Text(text = item.createdAt ?: "", color = AppColors.secondaryText, fontSize = 12.sp)
}
}
val amount = item.amount ?: 0
val isPositive = amount >= 0
val amountColor = if (isPositive) Color(0xFF00C853) else Color(0xFFFF1744)
Text(
text = (if (isPositive) "+" else "") + numberFormat.format(amount),
color = amountColor,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
}
}
if (hasNext) {
item {
Button(onClick = onLoadMore, modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)) {
Text("Load More")
}
}
}
}
}
@Composable
private fun HowToEarnList() {
val AppColors = LocalAppTheme.current
@Composable
fun RowItem(title: String, desc: String, amount: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(id = R.mipmap.paip_coin_img),
contentDescription = "earn",
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(12.dp))
)
Spacer(Modifier.size(12.dp))
Column {
Text(text = title, color = AppColors.text, fontSize = 16.sp, fontWeight = FontWeight.W600)
Spacer(Modifier.height(4.dp))
Text(text = desc, color = AppColors.secondaryText, fontSize = 12.sp)
}
}
Text(text = amount, color = Color(0xFF00C853), fontSize = 16.sp, fontWeight = FontWeight.Bold)
}
}
Column {
RowItem("New User Reward", "Register and get 500 Pai Coin", "+500")
RowItem("Daily Check-in", "Check in daily to earn Pai Coin", "+10-50")
RowItem("Invite Friends", "Earn Pai Coin for each friend invited", "+100")
RowItem("Complete Tasks", "Complete tasks to earn rewards", "+20-200")
RowItem("Recharge Pai Coin", "Multiple packages available, recharge now", ">")
}
}

View File

@@ -0,0 +1,17 @@
package com.aiosman.ravenow.ui.points
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@Composable
fun PointsBottomSheetHost() {
val show = PointsSheetManager.visible.collectAsState(false).value
if (show) {
PointsBottomSheet(
onClose = { PointsSheetManager.close() },
onRecharge = { PointsSheetManager.onRecharge?.invoke() }
)
}
}

View File

@@ -0,0 +1,24 @@
package com.aiosman.ravenow.ui.points
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
object PointsSheetManager {
private val _visible = MutableStateFlow(false)
val visible: StateFlow<Boolean> = _visible.asStateFlow()
var onRecharge: (() -> Unit)? = null
fun open(onRecharge: (() -> Unit)? = null) {
this.onRecharge = onRecharge
_visible.value = true
PointsViewModel.initLoad()
}
fun close() {
_visible.value = false
onRecharge = null
}
}

View File

@@ -0,0 +1,59 @@
package com.aiosman.ravenow.ui.points
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.AppStore
import com.aiosman.ravenow.data.PointService
import com.aiosman.ravenow.data.api.PointsChangeLog
import kotlinx.coroutines.launch
object PointsViewModel : ViewModel() {
val balance = PointService.pointsBalance
var logs by mutableStateOf<List<PointsChangeLog>>(emptyList())
var page by mutableStateOf(1)
var hasNext by mutableStateOf(true)
var loading by mutableStateOf(false)
fun initLoad() {
if (loading) return
viewModelScope.launch {
try {
loading = true
if (!AppStore.isGuest) {
PointService.refreshMyPointsBalance()
}
val r = PointService.getMyPointsChangeLogs(page = 1, pageSize = 20)
logs = r.list
hasNext = (r.page * r.pageSize) < r.total
page = r.page + 1
} catch (e: Exception) {
Log.e("PointsViewModel", "initLoad error", e)
} finally {
loading = false
}
}
}
fun loadMore() {
if (!hasNext || loading) return
viewModelScope.launch {
try {
loading = true
val r = PointService.getMyPointsChangeLogs(page = page, pageSize = 20)
logs = logs + r.list
hasNext = (r.page * r.pageSize) < r.total
page = r.page + 1
} catch (e: Exception) {
Log.e("PointsViewModel", "loadMore error", e)
} finally {
loading = false
}
}
}
}

View File

@@ -0,0 +1,171 @@
package com.aiosman.ravenow.ui.scan
import android.Manifest
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
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.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.aiosman.ravenow.LocalNavController
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.common.InputImage
import java.util.concurrent.Executors
@Composable
fun ScanQrScreen() {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val navController = LocalNavController.current
var cameraGranted by remember { mutableStateOf<Boolean?>(null) }
var hasResult by remember { mutableStateOf(false) }
val requestPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { granted ->
cameraGranted = granted
}
)
LaunchedEffect(Unit) {
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
}
val scanner = remember {
val options = BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.build()
BarcodeScanning.getClient(options)
}
when (cameraGranted) {
null -> {
// 等待权限结果
Box(modifier = Modifier.fillMaxSize().background(Color.Black))
}
false -> {
// 权限被拒绝
Column(
modifier = Modifier.fillMaxSize().background(Color.Black),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "需要相机权限以扫码", color = Color.White, modifier = Modifier.padding(16.dp))
Button(onClick = { navController.popBackStack() }) {
Text(text = "返回")
}
}
}
true -> {
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
PreviewView(ctx).apply {
this.scaleType = PreviewView.ScaleType.FILL_CENTER
}
},
update = { previewView ->
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder().build().also { p ->
p.setSurfaceProvider(previewView.surfaceProvider)
}
val analysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { imageAnalysis ->
val executor = Executors.newSingleThreadExecutor()
imageAnalysis.setAnalyzer(executor) { imageProxy ->
val mediaImage = imageProxy.image
if (mediaImage == null) {
imageProxy.close()
return@setAnalyzer
}
val image = InputImage.fromMediaImage(
mediaImage,
imageProxy.imageInfo.rotationDegrees
)
scanner.process(image)
.addOnSuccessListener { barcodes ->
if (!hasResult && !barcodes.isNullOrEmpty()) {
val first = barcodes.firstOrNull()
val rawValue = first?.rawValue ?: first?.displayValue
if (!rawValue.isNullOrBlank()) {
hasResult = true
navController.previousBackStackEntry
?.savedStateHandle
?.set("scan_result", rawValue)
Toast.makeText(context, rawValue, Toast.LENGTH_SHORT).show()
navController.popBackStack()
}
}
}
.addOnFailureListener {
// 忽略单帧失败
}
.addOnCompleteListener {
imageProxy.close()
}
}
}
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
analysis
)
} catch (_: Exception) {
// 绑定失败,忽略
}
}, ContextCompat.getMainExecutor(context))
}
)
// 简单文案提示
Text(
text = "对准二维码进行扫描",
color = Color.White,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 32.dp)
)
}
}
}
}

View File

@@ -0,0 +1,58 @@
package com.aiosman.ravenow.utils
import android.content.Context
import android.content.SharedPreferences
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
/**
* 搜索历史存储SharedPreferences + JSON
* - 最多保留 maxSize 条
* - 新记录放首位,去重(忽略前后空格)
*/
class SearchHistoryStore(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
private val gson = Gson()
fun getHistory(): List<String> {
val json = prefs.getString(KEY_HISTORY, "[]") ?: "[]"
return runCatching {
val type = object : TypeToken<List<String>>() {}.type
gson.fromJson<List<String>>(json, type) ?: emptyList()
}.getOrDefault(emptyList())
}
fun add(term: String) {
val normalized = term.trim()
if (normalized.isEmpty()) return
val current = getHistory().toMutableList()
current.removeAll { it.equals(normalized, ignoreCase = true) }
current.add(0, normalized)
while (current.size > MAX_SIZE) current.removeLast()
save(current)
}
fun remove(term: String) {
val normalized = term.trim()
val current = getHistory().toMutableList()
current.removeAll { it.equals(normalized, ignoreCase = true) }
save(current)
}
fun clear() {
save(emptyList())
}
private fun save(list: List<String>) {
prefs.edit().putString(KEY_HISTORY, gson.toJson(list)).apply()
}
companion object {
private const val PREF_NAME = "search_history_pref"
private const val KEY_HISTORY = "search_history_v1"
private const val MAX_SIZE = 10
}
}

View File

@@ -40,6 +40,8 @@ lifecycleProcess = "2.8.4"
playServicesAuth = "21.4.0"
rendering = "1.17.1"
zoomable = "1.6.1"
camerax = "1.3.4"
mlkitBarcode = "17.3.0"
[libraries]
accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanistSystemuicontroller" }
@@ -56,6 +58,9 @@ androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "medi
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "pagingRuntime" }
androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "pagingRuntime" }
androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" }
androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" }
androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "camerax" }
coil = { module = "io.coil-kt:coil", version.ref = "coil" }
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
compose-image-blurhash = { module = "com.github.orlando-dev-code:compose-image-blurhash", version.ref = "composeImageBlurhash" }
@@ -99,6 +104,7 @@ rendering = { group = "com.google.ar.sceneform", name = "rendering", version.ref
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "converterGson" }
zoomable = { module = "net.engawapg.lib:zoomable", version.ref = "zoomable" }
lottie = { module="com.airbnb.android:lottie-compose", version="6.6.10"}
mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkitBarcode" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }