新增“我的派币”功能
- **API & 数据层**: 新增积分(Points)相关的数据实体、API接口定义 (`getMyPointsBalance`, `getMyPointsChangeLogs`) 和 `PointService` 服务,用于管理和获取用户派币余额及交易记录。
- **UI & 交互**:
- 新增“我的派币”底部弹窗 (`PointsBottomSheet`),展示当前余额、累计收支、交易历史和如何赚取列表。
- 新增全局的弹窗管理器 `PointsSheetManager` 和 `PointsBottomSheetHost`,用于在应用内任何位置唤起派币弹窗。
- **功能集成**:
- 在用户个人主页的卡片上显示派币余额,并添加点击事件以打开弹窗。
- 应用启动和用户切换时刷新和清理派币数据,确保数据准确性。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 的通知点击
|
||||
|
||||
240
app/src/main/java/com/aiosman/ravenow/data/PointService.kt
Normal file
240
app/src/main/java/com/aiosman/ravenow/data/PointService.kt
Normal 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-DD,null 表示不限制
|
||||
* @param endTime 结束时间,格式:YYYY-MM-DD,null 表示不限制
|
||||
* @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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1016,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")
|
||||
@@ -1859,6 +1924,83 @@ 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 ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
// 游客模式下不加载个人动态和智能体
|
||||
|
||||
@@ -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, // 暗色模式下为白色,亮色模式下为黑色
|
||||
|
||||
@@ -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", ">")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user