diff --git a/app/src/main/java/com/aiosman/ravenow/AppState.kt b/app/src/main/java/com/aiosman/ravenow/AppState.kt index e5aa238..07dda58 100644 --- a/app/src/main/java/com/aiosman/ravenow/AppState.kt +++ b/app/src/main/java/com/aiosman/ravenow/AppState.kt @@ -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 diff --git a/app/src/main/java/com/aiosman/ravenow/MainActivity.kt b/app/src/main/java/com/aiosman/ravenow/MainActivity.kt index faf7459..c439be3 100644 --- a/app/src/main/java/com/aiosman/ravenow/MainActivity.kt +++ b/app/src/main/java/com/aiosman/ravenow/MainActivity.kt @@ -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 的通知点击 diff --git a/app/src/main/java/com/aiosman/ravenow/data/PointService.kt b/app/src/main/java/com/aiosman/ravenow/data/PointService.kt new file mode 100644 index 0000000..beb79d3 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/data/PointService.kt @@ -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(null) + val pointsBalance: StateFlow = _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 + } + } +} + diff --git a/app/src/main/java/com/aiosman/ravenow/data/api/RiderProAPI.kt b/app/src/main/java/com/aiosman/ravenow/data/api/RiderProAPI.kt index ef4e410..0bf4131 100644 --- a/app/src/main/java/com/aiosman/ravenow/data/api/RiderProAPI.kt +++ b/app/src/main/java/com/aiosman/ravenow/data/api/RiderProAPI.kt @@ -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, + @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 + // ========== 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> + + /** + * 获取我的积分变更日志 + * + * 功能说明: + * - 获取当前登录用户的积分变更日志列表 + * - 支持分页、时间范围筛选和变更类型筛选 + * + * @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 + // ========== Room Member Management API ========== /** diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/MyProfileViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/MyProfileViewModel.kt index b72b9f3..6ad9c87 100644 --- a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/MyProfileViewModel.kt +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/MyProfileViewModel.kt @@ -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 // 游客模式下不加载个人动态和智能体 diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/ProfileV3.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/ProfileV3.kt index f4e093e..3f4864f 100644 --- a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/ProfileV3.kt +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/profile/ProfileV3.kt @@ -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, // 暗色模式下为白色,亮色模式下为黑色 diff --git a/app/src/main/java/com/aiosman/ravenow/ui/points/PointsBottomSheet.kt b/app/src/main/java/com/aiosman/ravenow/ui/points/PointsBottomSheet.kt new file mode 100644 index 0000000..2611457 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/points/PointsBottomSheet.kt @@ -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, + 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", ">") + } +} + + diff --git a/app/src/main/java/com/aiosman/ravenow/ui/points/PointsBottomSheetHost.kt b/app/src/main/java/com/aiosman/ravenow/ui/points/PointsBottomSheetHost.kt new file mode 100644 index 0000000..03fa9c4 --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/points/PointsBottomSheetHost.kt @@ -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() } + ) + } +} + + diff --git a/app/src/main/java/com/aiosman/ravenow/ui/points/PointsSheetManager.kt b/app/src/main/java/com/aiosman/ravenow/ui/points/PointsSheetManager.kt new file mode 100644 index 0000000..ce96c5b --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/points/PointsSheetManager.kt @@ -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 = _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 + } +} + + diff --git a/app/src/main/java/com/aiosman/ravenow/ui/points/PointsViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/points/PointsViewModel.kt new file mode 100644 index 0000000..19c144d --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/points/PointsViewModel.kt @@ -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>(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 + } + } + } +} + +