新增“我的派币”功能

- **API & 数据层**: 新增积分(Points)相关的数据实体、API接口定义 (`getMyPointsBalance`, `getMyPointsChangeLogs`) 和 `PointService` 服务,用于管理和获取用户派币余额及交易记录。
- **UI & 交互**:
    - 新增“我的派币”底部弹窗 (`PointsBottomSheet`),展示当前余额、累计收支、交易历史和如何赚取列表。
    - 新增全局的弹窗管理器 `PointsSheetManager` 和 `PointsBottomSheetHost`,用于在应用内任何位置唤起派币弹窗。
- **功能集成**:
    - 在用户个人主页的卡片上显示派币余额,并添加点击事件以打开弹窗。
    - 应用启动和用户切换时刷新和清理派币数据,确保数据准确性。
This commit is contained in:
2025-11-10 22:53:35 +08:00
parent dba0ffd826
commit 1b70cb5cdb
10 changed files with 828 additions and 2 deletions

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

@@ -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

@@ -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 ==========
/**

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

@@ -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
}
}
}
}