diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/Moment.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/Moment.kt index 05a026f..2d27d4b 100644 --- a/app/src/main/java/com/aiosman/ravenow/ui/composables/Moment.kt +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/Moment.kt @@ -447,7 +447,7 @@ fun MomentContentGroup( } if (!momentEntity.momentTextContent.isNullOrEmpty()) { Text( - text = momentEntity.momentTextContent ?: "", + text = com.aiosman.ravenow.utils.Utils.unescapeHtml(momentEntity.momentTextContent), modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, end = 16.dp, top = 8.dp), diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/hot/Moment.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/hot/Moment.kt index d7f74c0..a62aedd 100644 --- a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/hot/Moment.kt +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/hot/Moment.kt @@ -216,7 +216,7 @@ fun DiscoverView() { // 文本内容区域,限制最大高度 if (!momentItem.momentTextContent.isNullOrEmpty()) { androidx.compose.material3.Text( - text = momentItem.momentTextContent ?: "", + text = com.aiosman.ravenow.utils.Utils.unescapeHtml(momentItem.momentTextContent), modifier = Modifier.fillMaxWidth(), fontSize = 12.sp, color = AppColors.text, diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/FullArticleModal.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/FullArticleModal.kt index 15d8c1d..34c83c8 100644 --- a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/FullArticleModal.kt +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/FullArticleModal.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -47,27 +48,38 @@ fun FullArticleModal( val context = LocalContext.current val configuration = LocalConfiguration.current val screenHeight = configuration.screenHeightDp.dp - val sheetHeight = screenHeight * 0.9f // 90% 高度 + // 根据屏幕高度计算最大高度,用于限制弹窗最大高度 + // 小屏幕(< 600dp):75%,中等屏幕(600-800dp):70%,大屏幕(> 800dp):65% + val maxSheetHeight = when { + screenHeight.value < 600 -> screenHeight * 0.75f + screenHeight.value <= 800 -> screenHeight * 0.7f + else -> screenHeight * 0.65f + } + + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + + // 确保弹窗从下往上展开 + androidx.compose.runtime.LaunchedEffect(Unit) { + sheetState.expand() + } ModalBottomSheet( onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true - ), - modifier = Modifier - .fillMaxWidth() - .height(sheetHeight), + sheetState = sheetState, containerColor = appColors.background, shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), ) { - Column( + Box( modifier = Modifier - .fillMaxSize() + .fillMaxWidth() + .heightIn(max = maxSheetHeight) ) { // 滚动内容 Column( modifier = Modifier - .weight(1f) + .fillMaxSize() .verticalScroll(rememberScrollState()) ) { // 新闻图片区域 - 固定高度和宽度 @@ -75,7 +87,6 @@ fun FullArticleModal( modifier = Modifier .fillMaxWidth() .height(250.dp) - .background(color = appColors.secondaryBackground) ) { if (moment.images.isNotEmpty()) { @@ -149,11 +160,13 @@ fun FullArticleModal( // 帖子内容 NewsContent( - content = if (moment.newsContent.isNotEmpty()) moment.newsContent else (moment.momentTextContent ?: ""), + content = com.aiosman.ravenow.utils.Utils.unescapeHtml( + if (moment.newsContent.isNotEmpty()) moment.newsContent else (moment.momentTextContent ?: "") + ), images = moment.images, context = context ) - Spacer(modifier = Modifier.height(200.dp)) + Spacer(modifier = Modifier.height(200.dp)) } } } diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/NewsCommentModal.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/NewsCommentModal.kt index 8453000..8e0f4ff 100644 --- a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/NewsCommentModal.kt +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/NewsCommentModal.kt @@ -9,6 +9,7 @@ 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars @@ -200,7 +201,9 @@ fun NewsCommentModal( } Column( - modifier = Modifier.background(AppColors.background) + modifier = Modifier + .fillMaxSize() + .background(AppColors.background) ) { Row( modifier = Modifier @@ -229,33 +232,29 @@ fun NewsCommentModal( } // 评论列表 - Column( + Box( modifier = Modifier .fillMaxWidth() .weight(1f) ) { - Box( - modifier = Modifier.fillMaxWidth() - ) { - LazyColumn { - item { - CommentContent( - viewModel = commentViewModel, - onLongClick = { comment -> - showCommentMenu = true - contextComment = comment - }, - onReply = { parentComment, _, _, _ -> - if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.COMMENT_MOMENT)) { - debouncedNavigation { - navController.navigate(NavigationRoute.Login.route) - } - } else { - replyComment = parentComment + LazyColumn { + item { + CommentContent( + viewModel = commentViewModel, + onLongClick = { comment -> + showCommentMenu = true + contextComment = comment + }, + onReply = { parentComment, _, _, _ -> + if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.COMMENT_MOMENT)) { + debouncedNavigation { + navController.navigate(NavigationRoute.Login.route) } + } else { + replyComment = parentComment } - ) - } + } + ) } } } diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/NewsScreen.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/NewsScreen.kt index 3137ff9..f7fec19 100644 --- a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/NewsScreen.kt +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/news/NewsScreen.kt @@ -11,7 +11,9 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -180,23 +182,38 @@ fun NewsScreen() { if (showCommentModal && selectedMoment != null) { val configuration = LocalConfiguration.current val screenHeight = configuration.screenHeightDp.dp - val sheetHeight = screenHeight * 0.67f // 三分之二高度 + // 根据屏幕高度计算最大高度,用于限制弹窗最大高度 + // 小屏幕(< 600dp):75%,中等屏幕(600-800dp):70%,大屏幕(> 800dp):65% + val maxSheetHeight = when { + screenHeight.value < 600 -> screenHeight * 0.75f + screenHeight.value <= 800 -> screenHeight * 0.7f + else -> screenHeight * 0.65f + } + + val commentSheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + + // 确保弹窗从下往上展开 + LaunchedEffect(Unit) { + commentSheetState.expand() + } ModalBottomSheet( onDismissRequest = { showCommentModal = false }, - sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true - ), - modifier = Modifier - .fillMaxWidth() - .height(sheetHeight), + sheetState = commentSheetState, containerColor = AppColors.background, shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), ) { - NewsCommentModal( - postId = selectedMoment?.id, + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = maxSheetHeight) + ) { + NewsCommentModal( + postId = selectedMoment?.id, commentCount = selectedMoment?.commentCount ?: 0, onDismiss = { showCommentModal = false @@ -207,7 +224,8 @@ fun NewsScreen() { onCommentDeleted = { selectedMoment?.id?.let { model.onDeleteComment(it) } } - ) + ) + } } } } @@ -287,7 +305,9 @@ fun NewsItem( // 新闻内容(超出使用省略号) Text( - text = if (moment.newsContent.isNotEmpty()) moment.newsContent else (moment.momentTextContent ?: ""), + text = com.aiosman.ravenow.utils.Utils.unescapeHtml( + if (moment.newsContent.isNotEmpty()) moment.newsContent else (moment.momentTextContent ?: "") + ), modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/recommend/PostRecommendationItem.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/recommend/PostRecommendationItem.kt index 5f3e721..d1e0016 100644 --- a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/recommend/PostRecommendationItem.kt +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/recommend/PostRecommendationItem.kt @@ -137,7 +137,7 @@ fun PostRecommendationItem( modifier = Modifier .fillMaxWidth(0.8f) .padding(top = 4.dp), - text = moment.momentTextContent ?: "", + text = com.aiosman.ravenow.utils.Utils.unescapeHtml(moment.momentTextContent), fontSize = 16.sp, color = Color.White, style = TextStyle(fontWeight = FontWeight.Bold), diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/recommend/VideoRecommendationItem.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/recommend/VideoRecommendationItem.kt index 857b713..5225b01 100644 --- a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/recommend/VideoRecommendationItem.kt +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/recommend/VideoRecommendationItem.kt @@ -283,7 +283,7 @@ fun VideoRecommendationItem( modifier = Modifier .fillMaxWidth(0.8f) .padding(top = 4.dp), - text = moment.momentTextContent ?: "", + text = com.aiosman.ravenow.utils.Utils.unescapeHtml(moment.momentTextContent), fontSize = 16.sp, color = Color.White, style = TextStyle(fontWeight = FontWeight.Bold), 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 d70e6cc..0553cd8 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 @@ -409,18 +409,6 @@ fun ProfileV3( bottomEnd = 32.dp ) ) - .let { - if (isSelf && isMain) { - it.noRippleClickable { - Intent(Intent.ACTION_PICK).apply { - type = "image/*" - pickBannerImageLauncher.launch(this) - } - } - } else { - it - } - } ) { CustomAsyncImage( LocalContext.current, @@ -773,48 +761,44 @@ fun TopNavigationBar( horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically ) { - // 左侧:互动数据卡片 - Row( - modifier = Modifier - .height(24.dp) - .background( - color = Color.White.copy(alpha = 0.52f), - shape = RoundedCornerShape(16.dp) + // 左侧:互动数据卡片(仅本人主页显示) + if (isSelf) { + Row( + modifier = Modifier + .height(24.dp) + .background( + color = Color.White.copy(alpha = 0.52f), + shape = RoundedCornerShape(16.dp) + ) + .border( + width = 0.5.dp, + color = cardBorderColor, // 根据背景透明度改变边框颜色 + shape = RoundedCornerShape(16.dp) + ) + .padding(horizontal = 8.dp) + .noRippleClickable { onPointsClick?.invoke() }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + // 互动图标 + Image( + painter = painterResource(id = R.mipmap.paip_coin_img), + contentDescription = "互动", + modifier = Modifier.size(24.dp), ) - .border( - width = 0.5.dp, - color = cardBorderColor, // 根据背景透明度改变边框颜色 - shape = RoundedCornerShape(16.dp) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = pointsBalanceState?.value?.balance?.let { numberFormat.format(it) } ?: "--", + fontSize = 14.sp, + fontWeight = FontWeight.W500, + color = if (AppState.darkMode) Color.White else Color.Black, // 暗色模式下为白色,亮色模式下为黑色 + textAlign = TextAlign.Center ) - .padding(horizontal = 8.dp) - .let { - if (isSelf) it.noRippleClickable { onPointsClick?.invoke() } else it - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - // 互动图标 - Image( - painter = painterResource(id = R.mipmap.paip_coin_img), - contentDescription = "互动", - modifier = Modifier.size(24.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - 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, // 暗色模式下为白色,亮色模式下为黑色 - textAlign = TextAlign.Center - ) + } + + Spacer(modifier = Modifier.width(16.dp)) } - Spacer(modifier = Modifier.width(16.dp)) - // 中间:分享图标 Image( painter = painterResource(id = R.mipmap.menu_icon), diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/shorts/ShortViewCompose.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/shorts/ShortViewCompose.kt index d1cd9e6..39d0018 100644 --- a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/shorts/ShortViewCompose.kt +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/shorts/ShortViewCompose.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape @@ -252,9 +253,6 @@ fun ShortViewCompose( val initialLayout = remember { mutableStateOf(true) } - val pauseIconVisibleState = remember { - mutableStateOf(false) - } Pager( modifier = Modifier @@ -266,7 +264,6 @@ fun ShortViewCompose( ) { // 使用 key 确保每个页面独立,避免状态混乱 androidx.compose.runtime.key(page) { - pauseIconVisibleState.value = false val currentMoment = if (videoMoments.isNotEmpty() && page < videoMoments.size) { videoMoments[page] } else { @@ -284,7 +281,6 @@ fun ShortViewCompose( pagerState = pagerState, pager = page, initialLayout = initialLayout, - pauseIconVisibleState = pauseIconVisibleState, VideoHeader = videoHeader, VideoBottom = videoBottom, onLikeClick = onLikeClick, @@ -312,7 +308,6 @@ private fun SingleVideoItemContent( pagerState: PagerState, pager: Int, initialLayout: MutableState, - pauseIconVisibleState: MutableState, VideoHeader: @Composable() () -> Unit = {}, VideoBottom: @Composable ((MomentEntity) -> Unit)? = null, onLikeClick: ((MomentEntity) -> Unit)? = null, @@ -323,6 +318,18 @@ private fun SingleVideoItemContent( onAvatarClick: ((MomentEntity) -> Unit)? = null, isPageVisible: Boolean = true ) { + // 将暂停状态移到每个视频项内部,使用 remember 保存,避免在点赞/关注时被重置 + val pauseIconVisibleState = remember(pager) { + mutableStateOf(false) + } + + // 当页面切换时,重置暂停状态 + LaunchedEffect(pager, pagerState.currentPage) { + if (pager != pagerState.currentPage) { + pauseIconVisibleState.value = false + } + } + Box( modifier = Modifier .fillMaxSize() @@ -379,7 +386,13 @@ fun VideoPlayer( val lifecycleOwner = LocalLifecycleOwner.current val configuration = androidx.compose.ui.platform.LocalConfiguration.current val screenHeight = configuration.screenHeightDp.dp - val sheetHeight = screenHeight * 0.7f // 屏幕的70%高度 + // 根据屏幕高度计算最大高度,用于限制弹窗最大高度 + // 小屏幕(< 600dp):75%,中等屏幕(600-800dp):70%,大屏幕(> 800dp):65% + val maxSheetHeight = when { + screenHeight.value < 600 -> screenHeight * 0.75f + screenHeight.value <= 800 -> screenHeight * 0.7f + else -> screenHeight * 0.65f + } var showCommentModal by remember { mutableStateOf(false) } var sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true @@ -417,18 +430,34 @@ fun VideoPlayer( } // 根据页面状态控制播放 + // 使用 remember 保存上一次的当前页面,只在页面真正切换时才重置暂停状态 + val previousCurrentPage = remember(pager) { mutableStateOf(pagerState.currentPage) } LaunchedEffect(pager, pagerState.currentPage, isPageVisible) { val isCurrentPage = pager == pagerState.currentPage && isPageVisible + val pageChanged = previousCurrentPage.value != pagerState.currentPage + if (isCurrentPage) { - // 当前页面且页面可见:恢复播放 - exoPlayer.playWhenReady = true - exoPlayer.play() - pauseIconVisibleState.value = false + // 当前页面且页面可见 + if (pageChanged) { + // 页面切换了(从其他页面切换到当前页面),恢复播放并重置暂停状态 + exoPlayer.playWhenReady = true + exoPlayer.play() + pauseIconVisibleState.value = false + previousCurrentPage.value = pagerState.currentPage + } else { + // 页面未切换,保持当前的播放/暂停状态 + // 如果用户手动暂停了,不要自动恢复播放;如果正在播放,保持播放 + if (!pauseIconVisibleState.value && !exoPlayer.isPlaying) { + exoPlayer.playWhenReady = true + exoPlayer.play() + } + } } else { // 非当前页面或页面不可见:立即暂停并停止准备播放 exoPlayer.playWhenReady = false exoPlayer.pause() pauseIconVisibleState.value = false + previousCurrentPage.value = pagerState.currentPage } } // 视频播放器容器 - 确保每个视频页面都被正确裁剪 @@ -519,39 +548,38 @@ fun VideoPlayer( horizontalAlignment = Alignment.CenterHorizontally ) { if (moment != null) { - // 使用 key 确保状态变化时重新组合 - androidx.compose.runtime.key(moment.id, moment.isFavorite) { - UserAvatar( - avatarUrl = moment.avatar, - onClick = { onAvatarClick?.invoke(moment) } - ) - VideoBtn( - icon = if (moment.liked) R.drawable.rider_pro_moment_liked else R.drawable.rider_pro_moment_like, - text = formatCount(moment.likeCount), - isActive = moment.liked, - onClick = { onLikeClick?.invoke(moment) } - ) - VideoBtn( - icon = R.mipmap.icon_comment, - text = formatCount(moment.commentCount), - onClick = { - showCommentModal = true - onCommentClick?.invoke(moment) - } - ) - VideoBtn( - icon = if (moment.isFavorite) R.mipmap.icon_variant_2 else R.mipmap.icon_collect, - text = formatCount(moment.favoriteCount), - isActive = false, // 收藏后不使用红色滤镜,保持图标原本颜色 - keepOriginalColor = moment.isFavorite, // 收藏后保持原始颜色 - onClick = { onFavoriteClick?.invoke(moment) } - ) - VideoBtn( - icon = R.mipmap.icon_share, - text = formatCount(moment.shareCount), - onClick = { onShareClick?.invoke(moment) } - ) - } + // 使用 key 确保状态变化时重新组合,包含所有可能变化的状态以避免不必要的重组 + // 移除 key,让 Compose 自动处理重组,避免头像闪烁 + UserAvatar( + avatarUrl = moment.avatar, + onClick = { onAvatarClick?.invoke(moment) } + ) + VideoBtn( + icon = if (moment.liked) R.drawable.rider_pro_moment_liked else R.drawable.rider_pro_moment_like, + text = formatCount(moment.likeCount), + isActive = moment.liked, + onClick = { onLikeClick?.invoke(moment) } + ) + VideoBtn( + icon = R.mipmap.icon_comment, + text = formatCount(moment.commentCount), + onClick = { + showCommentModal = true + onCommentClick?.invoke(moment) + } + ) + VideoBtn( + icon = if (moment.isFavorite) R.mipmap.icon_variant_2 else R.mipmap.icon_collect, + text = formatCount(moment.favoriteCount), + isActive = false, // 收藏后不使用红色滤镜,保持图标原本颜色 + keepOriginalColor = moment.isFavorite, // 收藏后保持原始颜色 + onClick = { onFavoriteClick?.invoke(moment) } + ) + VideoBtn( + icon = R.mipmap.icon_share, + text = formatCount(moment.shareCount), + onClick = { onShareClick?.invoke(moment) } + ) } else { UserAvatar() VideoBtn(icon = R.drawable.rider_pro_moment_like, text = "0") @@ -609,7 +637,7 @@ fun VideoPlayer( modifier = Modifier .fillMaxWidth() .padding(top = 4.dp), - text = moment.momentTextContent ?: "", + text = com.aiosman.ravenow.utils.Utils.unescapeHtml(moment.momentTextContent), fontSize = 16.sp, color = Color.White, style = TextStyle(fontWeight = FontWeight.Bold), @@ -637,7 +665,7 @@ fun VideoPlayer( Box( modifier = Modifier .fillMaxWidth() - .height(sheetHeight) + .heightIn(max = maxSheetHeight) ) { CommentModalContent( postId = moment.id, @@ -656,32 +684,36 @@ fun UserAvatar( avatarUrl: String? = null, onClick: (() -> Unit)? = null ) { - Box( - modifier = Modifier - .padding(bottom = 16.dp) - .size(40.dp) - .border(width = 3.dp, color = Color.White, shape = RoundedCornerShape(40.dp)) - .clip(RoundedCornerShape(40.dp)) - .then( - if (onClick != null) { - Modifier.noRippleClickable { onClick() } - } else { - Modifier - } - ) - ) { - if (avatarUrl != null && avatarUrl.isNotEmpty()) { - CustomAsyncImage( - imageUrl = avatarUrl, - contentDescription = "用户头像", - modifier = Modifier.fillMaxSize(), - defaultRes = R.drawable.default_avatar - ) - } else { - Image( - painter = painterResource(id = R.drawable.default_avatar), - contentDescription = "用户头像" - ) + // 使用 key 确保当 avatarUrl 不变时不会重新创建组件,避免闪烁 + androidx.compose.runtime.key(avatarUrl) { + Box( + modifier = Modifier + .padding(bottom = 16.dp) + .size(40.dp) + .border(width = 3.dp, color = Color.White, shape = RoundedCornerShape(40.dp)) + .clip(RoundedCornerShape(40.dp)) + .then( + if (onClick != null) { + Modifier.noRippleClickable { onClick() } + } else { + Modifier + } + ) + ) { + if (avatarUrl != null && avatarUrl.isNotEmpty()) { + CustomAsyncImage( + imageUrl = avatarUrl, + contentDescription = "用户头像", + modifier = Modifier.fillMaxSize(), + defaultRes = R.drawable.default_avatar, + showShimmer = false // 禁用 shimmer 效果,避免闪烁 + ) + } else { + Image( + painter = painterResource(id = R.drawable.default_avatar), + contentDescription = "用户头像" + ) + } } } } 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 index 5661431..a4d9862 100644 --- a/app/src/main/java/com/aiosman/ravenow/ui/points/PointsBottomSheet.kt +++ b/app/src/main/java/com/aiosman/ravenow/ui/points/PointsBottomSheet.kt @@ -2,11 +2,13 @@ package com.aiosman.ravenow.ui.points import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -18,11 +20,17 @@ 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.foundation.layout.offset +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.asPaddingValues 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.animation.core.animateDpAsState +import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -33,7 +41,22 @@ 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.draw.rotate +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -64,37 +87,134 @@ fun PointsBottomSheet( LaunchedEffect(Unit) { PointsViewModel.initLoad() + // 确保弹窗始终展开 + sheetState.expand() + } + + // 监听状态变化,确保弹窗始终展开(防止拖拽关闭和滑动) + LaunchedEffect(sheetState.currentValue, sheetState.targetValue, sheetState.isVisible) { + // 如果弹窗被拖拽关闭或位置发生变化,立即重新展开 + if (!sheetState.isVisible || sheetState.targetValue != androidx.compose.material3.SheetValue.Expanded) { + kotlinx.coroutines.delay(10) // 短暂延迟确保状态更新 + sheetState.expand() + } } + val statusBarPadding = WindowInsets.systemBars.asPaddingValues() + val configuration = LocalConfiguration.current + val screenHeight = configuration.screenHeightDp.dp + val offsetY = screenHeight * 0.07f - statusBarPadding.calculateTopPadding() + ModalBottomSheet( - onDismissRequest = onClose, + onDismissRequest = onClose, // 允许通过代码关闭(如返回按钮) sheetState = sheetState, - containerColor = AppColors.background + containerColor = AppColors.background, + dragHandle = null // 移除拖动手柄 ) { Column( modifier = Modifier .fillMaxWidth() - .fillMaxHeight(0.9f) - .padding(horizontal = 16.dp, vertical = 8.dp) + .fillMaxHeight(0.95f) + .offset(y = offsetY) + .padding( + start = 16.dp, + end = 16.dp, + bottom = 8.dp + ) ) { - // 头部 - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + // 头部 - 使用 Box 实现绝对居中布局 + Box( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + contentAlignment = Alignment.Center ) { - Text(text = stringResource(R.string.my_pai_coin), color = AppColors.text, fontSize = 20.sp, fontWeight = FontWeight.Bold) + // 左上角返回按钮 - 圆形白色背景,渐变边框,深灰色 × Box( modifier = Modifier + .align(Alignment.CenterStart) + .size(32.dp) + .clip(CircleShape) + .drawWithCache { + val borderWidth = 1.dp.toPx() + // 使用线性渐变,从顶部浅灰到底部白色 + val gradientBrush = Brush.verticalGradient( + colors = listOf( + Color(0xFFE8E8E8), + Color(0xFFF0F0F0), + Color(0xFFF8F8F8), + AppColors.background + ) + ) + onDrawWithContent { + drawContent() + drawCircle( + brush = gradientBrush, + radius = size.width / 2 - borderWidth / 2, + center = androidx.compose.ui.geometry.Offset(size.width / 2, size.height / 2), + style = Stroke(width = borderWidth) + ) + } + } + .background(AppColors.background) + .noRippleClickable { onClose() }, + contentAlignment = Alignment.Center + ) { + Text( + text = "×", + color = AppColors.text, + fontSize = 24.sp, + fontWeight = FontWeight.Normal + ) + } + + // 中间标题 - 绝对居中 + Text( + text = stringResource(R.string.my_pai_coin), + color = AppColors.text, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + + // 右侧充值按钮 - 白色背景,紫色文字,圆角,渐变边框 + Box( + modifier = Modifier + .align(Alignment.CenterEnd) .clip(RoundedCornerShape(16.dp)) - .background(Color(0xFFF1E9FF)) - .clickable { onRecharge() } + .drawWithCache { + val borderWidth = 1.dp.toPx() + val gradientBrush = Brush.linearGradient( + colors = listOf( + Color(0xFFE8E8E8), + Color(0xFFF0F0F0), + Color(0xFFF8F8F8), + AppColors.background + ) + ) + onDrawWithContent { + drawContent() + drawRoundRect( + brush = gradientBrush, + topLeft = androidx.compose.ui.geometry.Offset(borderWidth / 2, borderWidth / 2), + size = androidx.compose.ui.geometry.Size( + size.width - borderWidth, + size.height - borderWidth + ), + cornerRadius = CornerRadius(16.dp.toPx()), + style = Stroke(width = borderWidth) + ) + } + } + .background(AppColors.background) + .noRippleClickable { onRecharge() } .padding(horizontal = 12.dp, vertical = 6.dp) ) { - Text(text = stringResource(R.string.recharge), color = Color(0xFF6B46C1), fontSize = 14.sp, fontWeight = FontWeight.W600, - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .padding(0.dp) + Text( + text = stringResource(R.string.recharge), + color = Color(0xFF6B46C1), + fontSize = 14.sp, + fontWeight = FontWeight.W600 ) } } @@ -107,11 +227,22 @@ fun PointsBottomSheet( .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) .background(AppColors.nonActive) - .padding(16.dp) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Text(stringResource(R.string.current_balance), color = AppColors.secondaryText, fontSize = 14.sp) + // Current Balance 标签 - 居中 + Text( + text = stringResource(R.string.current_balance), + color = AppColors.secondaryText, + fontSize = 14.sp + ) Spacer(Modifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { + + // 余额数值 - 居中,图标在左侧 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { Image( painter = painterResource(id = R.mipmap.paip_coin_img), contentDescription = "coin", @@ -127,14 +258,30 @@ fun PointsBottomSheet( fontWeight = FontWeight.Bold ) } + Spacer(Modifier.height(12.dp)) + + // Total Earned 和 Total Spent - 居中排列 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 = stringResource(R.string.total_earned), color = AppColors.secondaryText, fontSize = 12.sp) + 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 + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.total_earned), + color = AppColors.secondaryText, + fontSize = 12.sp + ) } Box( modifier = Modifier @@ -142,9 +289,22 @@ fun PointsBottomSheet( .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 = stringResource(R.string.total_spent), color = AppColors.secondaryText, fontSize = 12.sp) + 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 + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.total_spent), + color = AppColors.secondaryText, + fontSize = 12.sp + ) } } } @@ -152,27 +312,10 @@ fun PointsBottomSheet( Spacer(Modifier.height(12.dp)) // 分段切换 - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically - ) { - TabItem( - text = stringResource(R.string.transaction_history), - isSelected = tab == 0, - onClick = { tab = 0 }, - modifier = Modifier.weight(1f) - ) - TabSpacer() - TabItem( - text = stringResource(R.string.how_to_earn), - isSelected = tab == 1, - onClick = { tab = 1 }, - modifier = Modifier.weight(1f) - ) - } + CustomTabBar( + selectedTab = tab, + onTabSelected = { tab = it } + ) Spacer(Modifier.height(8.dp)) @@ -184,22 +327,111 @@ fun PointsBottomSheet( hasNext = PointsViewModel.hasNext ) } else { - HowToEarnList() + HowToEarnList(onRecharge = onRecharge) } } } } @Composable -private fun SegmentItem(selected: Boolean, text: String, onClick: () -> Unit) { +private fun CustomTabBar( + selectedTab: Int, + onTabSelected: (Int) -> Unit +) { val AppColors = LocalAppTheme.current + val density = LocalDensity.current + + // 外层容器:灰色背景,圆角 Box( modifier = Modifier - .clip(RoundedCornerShape(20.dp)) - .background(if (selected) AppColors.background else Color.Transparent) - .padding(horizontal = 12.dp, vertical = 8.dp) + .fillMaxWidth() + .height(28.dp) + .clip(RoundedCornerShape(16.dp)) + .background(Color(0xFFE5E5E5)) ) { - Text(text = text, color = if (selected) AppColors.text else AppColors.secondaryText, fontSize = 14.sp, fontWeight = FontWeight.W600) + // 可滑动的白色背景 + val tabWidth = remember { mutableStateOf(0.dp) } + val gapWidth = 8.dp + val animatedOffset by animateDpAsState( + targetValue = if (selectedTab == 0) 0.dp else tabWidth.value + gapWidth, + animationSpec = tween(durationMillis = 300), + label = "tabOffset" + ) + + // 白色滑动背景(两侧圆角) + Box( + modifier = Modifier + .fillMaxHeight() + .width(tabWidth.value) + .offset(x = animatedOffset) + .clip(RoundedCornerShape(16.dp)) + .background(AppColors.background) + .border( + width = 1.dp, + color = Color(0xFFE5E5E5), + shape = RoundedCornerShape(16.dp) + ) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + // 交易历史标签(左侧) + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .onSizeChanged { size -> + tabWidth.value = with(density) { size.width.toDp() } + } + .noRippleClickable { onTabSelected(0) }, + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.transaction_history), + color = AppColors.text, + fontSize = 15.sp, + fontWeight = FontWeight.W600 + ) + } + + // 中间灰色填充区域 + Box( + modifier = Modifier + .width(gapWidth) + .fillMaxHeight() + ) + + // 如何赚取标签(右侧) + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .noRippleClickable { onTabSelected(1) }, + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.how_to_earn), + color = AppColors.text, + fontSize = 15.sp, + fontWeight = FontWeight.W600 + ) + } + } + } +} + +// 根据交易原因获取对应的图标资源ID +private fun getIconForReason(reason: String): Int { + return when (reason) { + PointService.ChangeReason.EARN_REGISTER -> R.mipmap.group_427319679 // 新用户注册奖励 + PointService.ChangeReason.EARN_DAILY -> R.mipmap.icons_calendar // 每日签到奖励 + PointService.ChangeReason.EARN_INVITE -> R.mipmap.icons_users // 邀请好友奖励 + PointService.ChangeReason.EARN_TASK -> R.mipmap.icons_task // 任务完成奖励 + PointService.ChangeReason.EARN_RECHARGE -> R.mipmap.icons_credit_card // 充值获得 + else -> R.mipmap.paip_coin_img // 其他类型使用默认图标 } } @@ -211,9 +443,25 @@ private fun PointsHistoryList( ) { val AppColors = LocalAppTheme.current val numberFormat = remember { NumberFormat.getNumberInstance(Locale.getDefault()) } + + // 创建 NestedScrollConnection 来阻止滚动事件传播到弹窗 + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + // 消费剩余的滚动事件,防止传播到 ModalBottomSheet 导致弹窗关闭 + return available + } + } + } LazyColumn( - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .nestedScroll(nestedScrollConnection) ) { items(items) { item -> Row( @@ -224,8 +472,10 @@ private fun PointsHistoryList( horizontalArrangement = Arrangement.SpaceBetween ) { Row(verticalAlignment = Alignment.CenterVertically) { + // 根据交易类型显示对应图标 + val iconResId = getIconForReason(item.reason ?: "") Image( - painter = painterResource(id = R.mipmap.paip_coin_img), + painter = painterResource(id = iconResId), contentDescription = "reason", modifier = Modifier .size(40.dp) @@ -261,55 +511,552 @@ private fun PointsHistoryList( } } +// 图标类型枚举 +private enum class EarnIconType { + GIFT_BOX, // 新用户奖励 - 橙色礼物盒 + CALENDAR, // 每日签到 - 蓝色日历 + PEOPLE, // 邀请好友 - 蓝色人物 + CHECKMARK, // 完成任务 - 紫色对勾 + CARDS // 充值派币 - 紫色卡片 +} + +// 赚取方式数据类 +private data class EarnMethod( + val title: String, + val desc: String, + val reward: String, + val iconColor: Color, + val iconBackgroundColor: Color, + val iconType: EarnIconType, + val isClickable: Boolean = false, + val onClick: (() -> Unit)? = null +) + @Composable -private fun HowToEarnList() { +private fun HowToEarnList(onRecharge: () -> Unit) { 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) - } + val earnMethods = listOf( + EarnMethod( + title = stringResource(R.string.new_user_reward), + desc = stringResource(R.string.new_user_reward_desc), + reward = "+500", + iconColor = Color(0xFFFF8D28), // RGB(255, 141, 40) + iconBackgroundColor = Color(0xFFFF8D28).copy(alpha = 0.06f), + iconType = EarnIconType.GIFT_BOX + ), + EarnMethod( + title = stringResource(R.string.daily_check_in), + desc = stringResource(R.string.daily_check_in_desc), + reward = "+10-50", + iconColor = Color(0xFF00C0E8), // RGB(0, 192, 232) + iconBackgroundColor = Color(0xFF00C0E8).copy(alpha = 0.06f), + iconType = EarnIconType.CALENDAR + ), + EarnMethod( + title = stringResource(R.string.invite_friends), + desc = stringResource(R.string.invite_friends_desc), + reward = "+100", + iconColor = Color(0xFF00C0E8), // RGB(0, 192, 232) + iconBackgroundColor = Color(0xFF00C0E8).copy(alpha = 0.06f), + iconType = EarnIconType.PEOPLE + ), + EarnMethod( + title = stringResource(R.string.complete_tasks), + desc = stringResource(R.string.complete_tasks_desc), + reward = "+20-200", + iconColor = Color(0xFFCB30E0), // RGB(203, 48, 224) + iconBackgroundColor = Color(0xFFCB30E0).copy(alpha = 0.06f), + iconType = EarnIconType.CHECKMARK + ), + EarnMethod( + title = stringResource(R.string.recharge_pai_coin), + desc = stringResource(R.string.recharge_pai_coin_desc), + reward = ">", + iconColor = Color(0xFF6155F5), // RGB(97, 85, 245) + iconBackgroundColor = Color(0xFF6155F5).copy(alpha = 0.06f), + iconType = EarnIconType.CARDS, + isClickable = true, + onClick = onRecharge + ) + ) + + // 创建 NestedScrollConnection 来阻止滚动事件传播到弹窗 + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + // 消费剩余的滚动事件,防止传播到 ModalBottomSheet 导致弹窗关闭 + return available } - Text(text = amount, color = Color(0xFF00C853), fontSize = 16.sp, fontWeight = FontWeight.Bold) } } - + LazyColumn( - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .nestedScroll(nestedScrollConnection), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - item { - RowItem(stringResource(R.string.new_user_reward), stringResource(R.string.new_user_reward_desc), "+500") - } - item { - RowItem(stringResource(R.string.daily_check_in), stringResource(R.string.daily_check_in_desc), "+10-50") - } - item { - RowItem(stringResource(R.string.invite_friends), stringResource(R.string.invite_friends_desc), "+100") - } - item { - RowItem(stringResource(R.string.complete_tasks), stringResource(R.string.complete_tasks_desc), "+20-200") - } - item { - RowItem(stringResource(R.string.recharge_pai_coin), stringResource(R.string.recharge_pai_coin_desc), ">") + items(earnMethods) { method -> + EarnMethodCard( + method = method, + AppColors = AppColors + ) } } } + +@Composable +private fun EarnMethodCard( + method: EarnMethod, + AppColors: com.aiosman.ravenow.AppThemeData +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(AppColors.nonActive) + .then( + if (method.isClickable) { + Modifier.clickable { method.onClick?.invoke() } + } else { + Modifier + } + ) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + // 图标背景 + Box( + modifier = Modifier + .size(50.dp) + .clip(RoundedCornerShape(16.dp)) + .background(method.iconBackgroundColor), + contentAlignment = Alignment.Center + ) { + CustomIcon( + iconType = method.iconType, + color = method.iconColor + ) + } + Spacer(modifier = Modifier.size(12.dp)) + Column { + Text( + text = method.title, + color = AppColors.text, + fontSize = 16.sp, + fontWeight = FontWeight.W600 + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = method.desc, + color = AppColors.secondaryText, + fontSize = 12.sp + ) + } + } + + // 奖励或箭头 + if (method.reward == ">") { + Image( + painter = painterResource(id = R.drawable.rider_pro_back_icon), + contentDescription = "Navigate", + modifier = Modifier + .size(16.dp) + .rotate(180f), + colorFilter = ColorFilter.tint(AppColors.secondaryText) + ) + } else { + Text( + text = method.reward, + color = Color(0xFF00C853), + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } +} + +@Composable +private fun CustomIcon( + iconType: EarnIconType, + color: Color, + modifier: Modifier = Modifier +) { + val iconResId = when (iconType) { + EarnIconType.GIFT_BOX -> R.mipmap.group_427319679 + EarnIconType.CALENDAR -> R.mipmap.icons_calendar + EarnIconType.PEOPLE -> R.mipmap.icons_users + EarnIconType.CHECKMARK -> R.mipmap.icons_task + EarnIconType.CARDS -> R.mipmap.icons_credit_card + } + + Image( + painter = painterResource(id = iconResId), + contentDescription = null, + modifier = modifier + ) +} + +@Composable +private fun GiftBoxIcon(color: Color) { + androidx.compose.foundation.Canvas( + modifier = Modifier.fillMaxSize() + ) { + val strokeWidth = 2.dp.toPx() + val size = size.width + + // 礼物盒主体 (17.1 x 14.0) + val boxWidth = size * 0.71f + val boxHeight = size * 0.58f + val boxLeft = (size - boxWidth) / 2 + val boxTop = size * 0.14f + + // 填充背景 + drawRect( + color = color.copy(alpha = 0.2f), + topLeft = androidx.compose.ui.geometry.Offset(boxLeft, boxTop), + size = androidx.compose.ui.geometry.Size(boxWidth, boxHeight) + ) + + // 描边 + drawRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(boxLeft, boxTop), + size = androidx.compose.ui.geometry.Size(boxWidth, boxHeight), + style = Stroke(width = strokeWidth) + ) + + // 顶部横线 (17.1 x 2.0) + val lineWidth = boxWidth + val lineHeight = size * 0.08f + drawRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(boxLeft, boxTop), + size = androidx.compose.ui.geometry.Size(lineWidth, lineHeight) + ) + + // 左侧竖线 (2.0 x 14.0) + val verticalLineWidth = size * 0.08f + val verticalLineHeight = boxHeight + drawRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(boxLeft, boxTop), + size = androidx.compose.ui.geometry.Size(verticalLineWidth, verticalLineHeight) + ) + + // 顶部两个小盒子 (7.2 x 5.3) + val smallBoxWidth = size * 0.3f + val smallBoxHeight = size * 0.22f + val smallBoxY = size * 0.29f + + // 右侧小盒子 + val rightBoxLeft = boxLeft + boxWidth * 0.21f + drawRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(rightBoxLeft, smallBoxY), + size = androidx.compose.ui.geometry.Size(smallBoxWidth, smallBoxHeight), + style = Stroke(width = strokeWidth) + ) + + // 左侧小盒子 + val leftBoxLeft = boxLeft - boxWidth * 0.21f + drawRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(leftBoxLeft, smallBoxY), + size = androidx.compose.ui.geometry.Size(smallBoxWidth, smallBoxHeight), + style = Stroke(width = strokeWidth) + ) + } +} + +@Composable +private fun CalendarIcon(color: Color) { + androidx.compose.foundation.Canvas( + modifier = Modifier.fillMaxSize() + ) { + val strokeWidth = 2.dp.toPx() + val size = size.width + + // 日历主体 (19.4 x 18.2) + val calendarWidth = size * 0.81f + val calendarHeight = size * 0.76f + val calendarLeft = (size - calendarWidth) / 2 + val calendarTop = size * 0.05f + + // 顶部横条 (19.1 x 5.8) + val topBarWidth = size * 0.8f + val topBarHeight = size * 0.24f + val topBarTop = size * 0.22f + + // 填充背景 + drawRect( + color = color.copy(alpha = 0.2f), + topLeft = androidx.compose.ui.geometry.Offset(calendarLeft, topBarTop), + size = androidx.compose.ui.geometry.Size(topBarWidth, topBarHeight) + ) + + // 日历主体填充 + drawRect( + color = color.copy(alpha = 0.2f), + topLeft = androidx.compose.ui.geometry.Offset(calendarLeft, calendarTop), + size = androidx.compose.ui.geometry.Size(calendarWidth, calendarHeight) + ) + + // 日历主体描边 + drawRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(calendarLeft, calendarTop), + size = androidx.compose.ui.geometry.Size(calendarWidth, calendarHeight), + style = Stroke(width = strokeWidth) + ) + + // 顶部横条描边 + drawRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(calendarLeft, topBarTop), + size = androidx.compose.ui.geometry.Size(topBarWidth, topBarHeight), + style = Stroke(width = strokeWidth) + ) + + // 顶部横线 (19.1 x 2.0) + val lineWidth = topBarWidth + val lineHeight = size * 0.1f + drawRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(calendarLeft, topBarTop + topBarHeight - lineHeight), + size = androidx.compose.ui.geometry.Size(lineWidth, lineHeight) + ) + + // 两个小圆点 (2.0 x 4.0) + val dotWidth = size * 0.08f + val dotHeight = size * 0.17f + val dotY = size * 0.17f + + // 左侧圆点 + val leftDotLeft = calendarLeft - size * 0.19f + drawRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(leftDotLeft, dotY), + size = androidx.compose.ui.geometry.Size(dotWidth, dotHeight) + ) + + // 右侧圆点 + val rightDotLeft = calendarLeft + calendarWidth - dotWidth + size * 0.175f + drawRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(rightDotLeft, dotY), + size = androidx.compose.ui.geometry.Size(dotWidth, dotHeight) + ) + } +} + +@Composable +private fun PeopleIcon(color: Color) { + androidx.compose.foundation.Canvas( + modifier = Modifier.fillMaxSize() + ) { + val strokeWidth = 2.dp.toPx() + val size = size.width + + // 左侧人物头部 (8.0 x 8.0) + val headSize = size * 0.33f + val headLeft = size * 0.1f + val headTop = size * 0.23f + + // 填充背景 + drawRect( + color = color.copy(alpha = 0.2f), + topLeft = androidx.compose.ui.geometry.Offset(headLeft, headTop), + size = androidx.compose.ui.geometry.Size(headSize, headSize) + ) + + // 描边 + drawRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(headLeft, headTop), + size = androidx.compose.ui.geometry.Size(headSize, headSize), + style = Stroke(width = strokeWidth) + ) + + // 左侧身体 (3.0 x 6.0) + val bodyWidth = size * 0.125f + val bodyHeight = size * 0.25f + val leftBodyLeft = headLeft + headSize + size * 0.1f + drawRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(leftBodyLeft, headTop), + size = androidx.compose.ui.geometry.Size(bodyWidth, bodyHeight), + style = Stroke(width = strokeWidth) + ) + + // 右侧人物头部 (14.0 x 8.0) + val rightHeadWidth = size * 0.58f + val rightHeadHeight = headSize + val rightHeadLeft = headLeft + val rightHeadTop = headTop + headSize + size * 0.1f + + // 填充背景 + drawRect( + color = color.copy(alpha = 0.2f), + topLeft = androidx.compose.ui.geometry.Offset(rightHeadLeft, rightHeadTop), + size = androidx.compose.ui.geometry.Size(rightHeadWidth, rightHeadHeight) + ) + + // 描边 + drawRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(rightHeadLeft, rightHeadTop), + size = androidx.compose.ui.geometry.Size(rightHeadWidth, rightHeadHeight), + style = Stroke(width = strokeWidth) + ) + + // 右侧身体 (3.0 x 4.9) + val rightBodyWidth = bodyWidth + val rightBodyHeight = size * 0.2f + val rightBodyLeft = rightHeadLeft + rightHeadWidth - rightBodyWidth + size * 0.1f + drawRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(rightBodyLeft, rightHeadTop), + size = androidx.compose.ui.geometry.Size(rightBodyWidth, rightBodyHeight), + style = Stroke(width = strokeWidth) + ) + } +} + +@Composable +private fun CheckmarkIcon(color: Color) { + androidx.compose.foundation.Canvas( + modifier = Modifier.fillMaxSize() + ) { + val strokeWidth = 2.dp.toPx() + val size = size.width + + // 方框 (15.6 x 17.1) + val boxWidth = size * 0.65f + val boxHeight = size * 0.71f + val boxLeft = (size - boxWidth) / 2 + val boxTop = size * 0.05f + + // 描边 + drawRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(boxLeft, boxTop), + size = androidx.compose.ui.geometry.Size(boxWidth, boxHeight), + style = Stroke(width = strokeWidth) + ) + + // 顶部横条 (9.0 x 4.7) + val topBarWidth = size * 0.375f + val topBarHeight = size * 0.2f + val topBarLeft = (size - topBarWidth) / 2 + val topBarTop = size * 0.31f + + // 填充背景 + drawRect( + color = color.copy(alpha = 0.2f), + topLeft = androidx.compose.ui.geometry.Offset(topBarLeft, topBarTop), + size = androidx.compose.ui.geometry.Size(topBarWidth, topBarHeight) + ) + + // 描边 + drawRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(topBarLeft, topBarTop), + size = androidx.compose.ui.geometry.Size(topBarWidth, topBarHeight), + style = Stroke(width = strokeWidth) + ) + + // 对勾圆圈 (6.0 x 6.0) + val circleSize = size * 0.25f + val circleLeft = (size - circleSize) / 2 + val circleTop = boxTop + boxHeight * 0.12f + + drawRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(circleLeft, circleTop), + size = androidx.compose.ui.geometry.Size(circleSize, circleSize), + style = Stroke(width = strokeWidth) + ) + } +} + +@Composable +private fun CardsIcon(color: Color) { + androidx.compose.foundation.Canvas( + modifier = Modifier.fillMaxSize() + ) { + val strokeWidth = 2.dp.toPx() + val size = size.width + + // 底层卡片 (16.1 x 13.0) + val bottomCardWidth = size * 0.67f + val bottomCardHeight = size * 0.54f + val bottomCardLeft = size * 0.067f + val bottomCardTop = size * 0.08f + + // 填充背景 + drawRect( + color = color.copy(alpha = 0.2f), + topLeft = androidx.compose.ui.geometry.Offset(bottomCardLeft, bottomCardTop), + size = androidx.compose.ui.geometry.Size(bottomCardWidth, bottomCardHeight) + ) + + // 描边 + drawRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(bottomCardLeft, bottomCardTop), + size = androidx.compose.ui.geometry.Size(bottomCardWidth, bottomCardHeight), + style = Stroke(width = strokeWidth) + ) + + // 顶层卡片 (15.8 x 11.8) + val topCardWidth = size * 0.66f + val topCardHeight = size * 0.49f + val topCardLeft = size * 0.075f + val topCardTop = size * 0.1f + + // 描边 + drawRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(topCardLeft, topCardTop), + size = androidx.compose.ui.geometry.Size(topCardWidth, topCardHeight), + style = Stroke(width = strokeWidth) + ) + + // 顶部横条 (16.1 x 4.6) + val topBarWidth = bottomCardWidth + val topBarHeight = size * 0.19f + val topBarLeft = bottomCardLeft + val topBarTop = bottomCardTop + + // 描边 + drawRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(topBarLeft, topBarTop), + size = androidx.compose.ui.geometry.Size(topBarWidth, topBarHeight), + style = Stroke(width = strokeWidth) + ) + + // 小圆点 (2.7 x 2.0) + val dotWidth = size * 0.11f + val dotHeight = size * 0.08f + val dotLeft = bottomCardLeft + bottomCardWidth * 0.32f + val dotTop = bottomCardTop + bottomCardHeight * 0.2f + + drawRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(dotLeft, dotTop), + size = androidx.compose.ui.geometry.Size(dotWidth, dotHeight) + ) + } +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/post/Post.kt b/app/src/main/java/com/aiosman/ravenow/ui/post/Post.kt index c022305..74e148f 100644 --- a/app/src/main/java/com/aiosman/ravenow/ui/post/Post.kt +++ b/app/src/main/java/com/aiosman/ravenow/ui/post/Post.kt @@ -1281,7 +1281,7 @@ fun PostDetails( ) { if (!momentEntity?.momentTextContent.isNullOrEmpty()) { Text( - text = momentEntity?.momentTextContent ?: "", + text = com.aiosman.ravenow.utils.Utils.unescapeHtml(momentEntity?.momentTextContent), fontSize = 14.sp, color = AppColors.text, ) @@ -1396,7 +1396,7 @@ fun CommentItem( Text( - text = commentEntity.comment, + text = com.aiosman.ravenow.utils.Utils.unescapeHtml(commentEntity.comment), fontSize = 13.sp, maxLines = Int.MAX_VALUE, softWrap = true, diff --git a/app/src/main/java/com/aiosman/ravenow/utils/Utils.kt b/app/src/main/java/com/aiosman/ravenow/utils/Utils.kt index f47c465..b5d03e1 100644 --- a/app/src/main/java/com/aiosman/ravenow/utils/Utils.kt +++ b/app/src/main/java/com/aiosman/ravenow/utils/Utils.kt @@ -116,4 +116,22 @@ object Utils { return compressedFile } + + /** + * HTML 反转义函数 + * 将 HTML 实体编码转换为对应的字符 + * 例如:' -> ', " -> ", & -> &, < -> <, > -> > + */ + fun unescapeHtml(html: String?): String { + if (html == null) return "" + + return html + .replace("'", "'") + .replace(""", "\"") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + } } \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/group_427319679.png b/app/src/main/res/mipmap-hdpi/group_427319679.png new file mode 100644 index 0000000..cc7b0cb Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/group_427319679.png differ diff --git a/app/src/main/res/mipmap-hdpi/icons_calendar.png b/app/src/main/res/mipmap-hdpi/icons_calendar.png new file mode 100644 index 0000000..ce8d450 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icons_calendar.png differ diff --git a/app/src/main/res/mipmap-hdpi/icons_credit_card.png b/app/src/main/res/mipmap-hdpi/icons_credit_card.png new file mode 100644 index 0000000..99cc751 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icons_credit_card.png differ diff --git a/app/src/main/res/mipmap-hdpi/icons_task.png b/app/src/main/res/mipmap-hdpi/icons_task.png new file mode 100644 index 0000000..1baac4a Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icons_task.png differ diff --git a/app/src/main/res/mipmap-hdpi/icons_users.png b/app/src/main/res/mipmap-hdpi/icons_users.png new file mode 100644 index 0000000..1d88d17 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icons_users.png differ diff --git a/app/src/main/res/mipmap-mdpi/group_427319679.png b/app/src/main/res/mipmap-mdpi/group_427319679.png new file mode 100644 index 0000000..e76a378 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/group_427319679.png differ diff --git a/app/src/main/res/mipmap-mdpi/icons_calendar.png b/app/src/main/res/mipmap-mdpi/icons_calendar.png new file mode 100644 index 0000000..3f43136 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icons_calendar.png differ diff --git a/app/src/main/res/mipmap-mdpi/icons_credit_card.png b/app/src/main/res/mipmap-mdpi/icons_credit_card.png new file mode 100644 index 0000000..ff2ea61 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icons_credit_card.png differ diff --git a/app/src/main/res/mipmap-mdpi/icons_task.png b/app/src/main/res/mipmap-mdpi/icons_task.png new file mode 100644 index 0000000..ca4596d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icons_task.png differ diff --git a/app/src/main/res/mipmap-mdpi/icons_users.png b/app/src/main/res/mipmap-mdpi/icons_users.png new file mode 100644 index 0000000..922f203 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icons_users.png differ diff --git a/app/src/main/res/mipmap-xhdpi/group_427319679.png b/app/src/main/res/mipmap-xhdpi/group_427319679.png new file mode 100644 index 0000000..befebcd Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/group_427319679.png differ diff --git a/app/src/main/res/mipmap-xhdpi/icons_calendar.png b/app/src/main/res/mipmap-xhdpi/icons_calendar.png new file mode 100644 index 0000000..6fe64e0 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icons_calendar.png differ diff --git a/app/src/main/res/mipmap-xhdpi/icons_credit_card.png b/app/src/main/res/mipmap-xhdpi/icons_credit_card.png new file mode 100644 index 0000000..bccfcc8 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icons_credit_card.png differ diff --git a/app/src/main/res/mipmap-xhdpi/icons_task.png b/app/src/main/res/mipmap-xhdpi/icons_task.png new file mode 100644 index 0000000..9af007e Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icons_task.png differ diff --git a/app/src/main/res/mipmap-xhdpi/icons_users.png b/app/src/main/res/mipmap-xhdpi/icons_users.png new file mode 100644 index 0000000..4b525ac Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icons_users.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/group_427319679.png b/app/src/main/res/mipmap-xxhdpi/group_427319679.png new file mode 100644 index 0000000..bcd43f9 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/group_427319679.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/icons_calendar.png b/app/src/main/res/mipmap-xxhdpi/icons_calendar.png new file mode 100644 index 0000000..ebd8546 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icons_calendar.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/icons_credit_card.png b/app/src/main/res/mipmap-xxhdpi/icons_credit_card.png new file mode 100644 index 0000000..4a20c36 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icons_credit_card.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/icons_task.png b/app/src/main/res/mipmap-xxhdpi/icons_task.png new file mode 100644 index 0000000..9189f47 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icons_task.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/icons_users.png b/app/src/main/res/mipmap-xxhdpi/icons_users.png new file mode 100644 index 0000000..476a589 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icons_users.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/group_427319679.png b/app/src/main/res/mipmap-xxxhdpi/group_427319679.png new file mode 100644 index 0000000..001bed2 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/group_427319679.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/icons_calendar.png b/app/src/main/res/mipmap-xxxhdpi/icons_calendar.png new file mode 100644 index 0000000..8b81f10 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icons_calendar.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/icons_credit_card.png b/app/src/main/res/mipmap-xxxhdpi/icons_credit_card.png new file mode 100644 index 0000000..c724ab4 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icons_credit_card.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/icons_task.png b/app/src/main/res/mipmap-xxxhdpi/icons_task.png new file mode 100644 index 0000000..2815dcd Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icons_task.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/icons_users.png b/app/src/main/res/mipmap-xxxhdpi/icons_users.png new file mode 100644 index 0000000..d4ec18b Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icons_users.png differ