Merge pull request #91 from Kevinlinpr/nagisa

界面调整、以及修复bug等
This commit is contained in:
2025-11-26 10:17:51 +08:00
committed by GitHub
107 changed files with 527 additions and 184 deletions

View File

@@ -0,0 +1,15 @@
package com.aiosman.ravenow.ui.account
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@Composable
fun MbtiBottomSheetHost() {
val show = MbtiSheetManager.visible.collectAsState(false).value
if (show) {
MbtiSelectBottomSheet(
onClose = { MbtiSheetManager.close() }
)
}
}

View File

@@ -1,42 +1,60 @@
package com.aiosman.ravenow.ui.account
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
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.layout.systemBars
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
// MBTI类型列表
val MBTI_TYPES = listOf(
@@ -46,96 +64,285 @@ val MBTI_TYPES = listOf(
"ISTP", "ISFP", "ESTP", "ESFP"
)
fun getMbtiImageResId(mbti: String, isDarkMode: Boolean): Int {
return when {
isDarkMode && mbti == "ENTP" -> R.mipmap.anmbti_entp
isDarkMode && mbti == "ESTP" -> R.mipmap.anmbti_estp
isDarkMode && mbti == "ENTJ" -> R.mipmap.anmbti_entj
else -> when (mbti) {
"INTJ" -> R.mipmap.mbti_intj
"INTP" -> R.mipmap.mbti_intp
"ENTJ" -> R.mipmap.mbti_entj
"ENTP" -> R.mipmap.mbti_entp
"INFJ" -> R.mipmap.mbti_infj
"INFP" -> R.mipmap.mbti_infp
"ENFJ" -> R.mipmap.mbti_enfj
"ENFP" -> R.mipmap.mbti_enfp
"ISTJ" -> R.mipmap.mbti_istj
"ISFJ" -> R.mipmap.mbti_isfj
"ESTJ" -> R.mipmap.mbti_estj
"ESFJ" -> R.mipmap.mbti_esfj
"ISTP" -> R.mipmap.mbti_istp
"ISFP" -> R.mipmap.mbti_isfp
"ESTP" -> R.mipmap.mbti_estp
"ESFP" -> R.mipmap.mbti_esfp
else -> R.mipmap.xingzuo
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MbtiSelectScreen() {
val navController = LocalNavController.current
fun MbtiSelectBottomSheet(
onClose: () -> Unit
) {
val appColors = LocalAppTheme.current
val isDarkMode = AppState.darkMode
val model = AccountEditViewModel
val currentMbti = model.mbti
val sheetBackgroundColor = if (isDarkMode) {
appColors.secondaryBackground
} else {
Color(0xFFFFFFFF)
}
Column(
modifier = Modifier
.fillMaxSize()
.background(appColors.profileBackground)
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
// 确保弹窗展开
LaunchedEffect(Unit) {
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,
sheetState = sheetState,
containerColor = sheetBackgroundColor,
dragHandle = null
) {
// 头部
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
.fillMaxHeight(0.95f)
.offset(y = offsetY)
.padding(
start = 16.dp,
end = 16.dp,
bottom = 8.dp
)
) {
NoticeScreenHeader(
title = stringResource(R.string.choose_mbti),
moreIcon = false
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
// 头部 - 使用 Box 实现绝对居中布局
Box(
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
contentAlignment = Alignment.Center
) {
val cancelButtonGradientColors = if (isDarkMode) {
listOf(
Color(0xFF3A3A3C),
Color(0xFF2C2C2E)
)
} else {
listOf(
Color(0xFFFFFFFF),
Color(0xFFF8F8F8)
)
}
val cancelButtonContentColor = if (isDarkMode) Color(0xFFFFFFFF) else Color(0xFF404040)
// 左上角返回按钮
Row(
modifier = Modifier
.align(Alignment.CenterStart)
.height(36.dp)
.clip(RoundedCornerShape(18.dp))
.background(
brush = Brush.linearGradient(
colors = cancelButtonGradientColors
)
)
.noRippleClickable { onClose() }
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
// 左箭头图标
Image(
painter = painterResource(id = R.drawable.rider_pro_back_icon),
contentDescription = null,
modifier = Modifier.size(17.dp),
colorFilter = ColorFilter.tint(cancelButtonContentColor)
)
// "取消" 文字
Text(
text = "取消",
fontSize = 17.sp,
fontWeight = FontWeight.Medium,
color = cancelButtonContentColor,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
// 列表
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 8.dp)
// 中间标题 - 绝对居中
Text(
text = stringResource(R.string.choose_mbti),
color = appColors.text,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
Spacer(Modifier.height(12.dp))
// 创建 NestedScrollConnection 来阻止滚动事件向上传播到 ModalBottomSheet
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// 不消费任何事件,让 LazyVerticalGrid 先处理
return Offset.Zero
}
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
// 消费 LazyVerticalGrid 处理后的剩余滚动事件,防止传递到 ModalBottomSheet
return available
}
override suspend fun onPreFling(available: Velocity): Velocity {
// 不消费惯性滚动,让 LazyVerticalGrid 先处理
return Velocity.Zero
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
// 消费 LazyVerticalGrid 处理后的剩余惯性滚动,防止传递到 ModalBottomSheet
return available
}
}
}
// 网格列表 - 2列
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.nestedScroll(nestedScrollConnection),
contentPadding = PaddingValues(
start = 8.dp,
top = 8.dp,
end = 8.dp,
bottom = 8.dp
),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(MBTI_TYPES) { mbti ->
MBTIItem(
itemsIndexed(MBTI_TYPES) { index, mbti ->
MbtiItem(
mbti = mbti,
isSelected = mbti == currentMbti,
onClick = {
// 保存MBTI类型
model.mbti = mbti
// 立即保存到本地存储,确保选择后立即生效
AppState.UserId?.let { uid ->
com.aiosman.ravenow.AppStore.setUserMbti(uid, mbti)
}
navController.navigateUp()
onClose()
}
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
}
}
// 保留原有的 MbtiSelectScreen 用于导航路由(如果需要)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MBTIItem(
fun MbtiSelectScreen() {
val navController = LocalNavController.current
MbtiSelectBottomSheet(
onClose = {
navController.navigateUp()
}
)
}
@Composable
fun MbtiItem(
mbti: String,
isSelected: Boolean,
onClick: () -> Unit
) {
val appColors = LocalAppTheme.current
val isDarkMode = AppState.darkMode
Box(
// 卡片背景色
val cardBackgroundColor = if (isDarkMode) {
Color(0xFF2A2A2A) // 比 secondaryBackground (0xFF1C1C1C) 更亮的灰色
} else {
Color(0xFFFAF9FB)
}
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(if (isSelected) appColors.main.copy(alpha = 0.1f) else Color.White)
.aspectRatio(1.1f)
.shadow(
elevation = if (isDarkMode) 8.dp else 2.dp,
shape = RoundedCornerShape(21.dp),
spotColor = if (isDarkMode) Color.Black.copy(alpha = 0.5f) else Color.Black.copy(alpha = 0.1f)
)
.clip(RoundedCornerShape(21.dp))
.background(cardBackgroundColor)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
onClick()
}
.padding(16.dp)
.padding(horizontal = 24.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
// MBTI图标 - 使用占位图片
Box(
modifier = Modifier.size(100.dp),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(id = getMbtiImageResId(mbti, isDarkMode)),
contentDescription = mbti,
modifier = Modifier.size(100.dp)
)
}
// MBTI名称 - 使用负间距让文本向上移动,与图标更靠近
Text(
text = mbti,
fontSize = 17.sp,
fontWeight = FontWeight.Normal,
color = if (isSelected) appColors.main else appColors.text,
modifier = Modifier.weight(1f)
)
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
modifier = Modifier.size(20.dp),
tint = appColors.main
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = appColors.text,
textAlign = TextAlign.Center,
modifier = Modifier.offset(y = (-20).dp)
)
}
}
}
}

View File

@@ -0,0 +1,19 @@
package com.aiosman.ravenow.ui.account
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
object MbtiSheetManager {
private val _visible = MutableStateFlow(false)
val visible: StateFlow<Boolean> = _visible.asStateFlow()
fun open() {
_visible.value = true
}
fun close() {
_visible.value = false
}
}

View File

@@ -293,10 +293,6 @@ fun ZodiacSelectBottomSheet(
onClick = {
// 保存当前语言的星座文本
model.zodiac = zodiacText
// 立即保存到本地存储,确保选择后立即生效
AppState.UserId?.let { uid ->
com.aiosman.ravenow.AppStore.setUserZodiac(uid, zodiacText)
}
onClose()
}
)

View File

@@ -77,6 +77,8 @@ import java.io.File
import androidx.activity.compose.BackHandler
import com.aiosman.ravenow.ui.account.ZodiacBottomSheetHost
import com.aiosman.ravenow.ui.account.ZodiacSheetManager
import com.aiosman.ravenow.ui.account.MbtiBottomSheetHost
import com.aiosman.ravenow.ui.account.MbtiSheetManager
/**
* 编辑用户资料界面
@@ -194,6 +196,8 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
) {
// 挂载星座选择弹窗
ZodiacBottomSheetHost()
// 挂载MBTI选择弹窗
MbtiBottomSheetHost()
Box(
modifier = Modifier
@@ -425,9 +429,7 @@ fun AccountEditScreen2(onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,)
iconResDark = null, // TODO: 添加MBTI暗色模式图标
iconResLight = null, // TODO: 添加MBTI亮色模式图标
onClick = {
debouncedNavigation {
navController.navigate(NavigationRoute.MbtiSelect.route)
}
MbtiSheetManager.open()
}
)

View File

@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -31,6 +32,7 @@ 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.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@@ -79,8 +81,9 @@ class CommentModalViewModel(
fun CommentModalContent(
postId: Int? = null,
commentCount: Int = 0,
onCommentAdded: () -> Unit = {},
onDismiss: () -> Unit = {}
onDismiss: () -> Unit = {},
showTitle: Boolean = true,
onCommentAdded: () -> Unit = {}
) {
val model = viewModel<CommentModalViewModel>(
key = "CommentModalViewModel_$postId",
@@ -161,6 +164,23 @@ fun CommentModalContent(
modifier = Modifier
.fillMaxSize()
) {
// 拖动手柄
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 12.dp),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.width(40.dp)
.height(4.dp)
.clip(RoundedCornerShape(50))
.background(AppColors.divider)
)
}
if (showTitle) {
Box(
modifier = Modifier
.fillMaxWidth()
@@ -175,14 +195,11 @@ fun CommentModalContent(
modifier = Modifier.align(Alignment.Center)
)
}
HorizontalDivider(
color = AppColors.divider
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
.padding(horizontal = 20.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {

View File

@@ -17,15 +17,19 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -155,7 +159,10 @@ fun FavouriteListPage() {
.clip(RoundedCornerShape(8.dp)),
context = context
)
if (momentItem.images.size > 1) {
val isVideoMoment = momentItem.images.isEmpty() && !momentItem.videos.isNullOrEmpty()
if (momentItem.images.size > 1 || (momentItem.videos?.size ?: 0) > 1) {
Box(
modifier = Modifier
.padding(top = 8.dp, end = 8.dp)
@@ -168,6 +175,31 @@ fun FavouriteListPage() {
)
}
}
if (isVideoMoment) {
Box(
modifier = Modifier
.padding(top = 8.dp, end = 8.dp)
.align(Alignment.TopEnd)
) {
Box(
modifier = Modifier
.size(24.dp)
.background(
color = Color.Black.copy(alpha = 0.4f),
shape = RoundedCornerShape(12.dp)
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "",
tint = Color.White,
modifier = Modifier.size(16.dp)
)
}
}
}
}
}
}

View File

@@ -2,10 +2,6 @@ package com.aiosman.ravenow.ui.index
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
@@ -140,6 +136,7 @@ fun IndexScreen() {
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = drawerState.isOpen,
scrimColor = Color.Black.copy(alpha = 0.6f),
drawerContent = {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
SideMenuContent(
@@ -525,8 +522,6 @@ fun SideMenuContent(
} else {
Color(0xFFFAF9FB) // 亮色模式:浅灰色
}
// 遮罩颜色 黑色透明度0.6
val overlayColor = Color.Black.copy(alpha = 0.6f)
// 卡片背景色 - 根据暗色模式适配
val cardBackgroundColor = if (darkModeEnabled) {
appColors.background // 暗色模式:深色背景
@@ -546,24 +541,6 @@ fun SideMenuContent(
modifier = Modifier
.fillMaxSize()
) {
// 左侧半透明遮罩(平滑淡入淡出)
val overlayTransition = updateTransition(targetState = isDrawerOpen, label = "overlay")
val overlayAlpha by overlayTransition.animateFloat(
transitionSpec = {
if (targetState) {
tween(durationMillis = 400, easing = LinearOutSlowInEasing)
} else {
tween(durationMillis = 300, easing = FastOutLinearInEasing)
}
},
label = "overlayAlpha"
) { open -> if (open) 0.6f else 0f }
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = overlayAlpha))
)
// 右侧菜单面板
Box(
modifier = Modifier

View File

@@ -33,8 +33,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
@@ -97,6 +99,9 @@ fun VideoRecommendationItem(
skipPartiallyExpanded = true
)
var pauseIconVisibleState by remember { mutableStateOf(false) }
// 防抖:记录上次双击时间,防止快速重复双击
val lastDoubleTapTime = remember { mutableStateOf(0L) }
val doubleTapDebounceTime = 500L // 500ms 防抖时间
val exoPlayer = remember(videoUrl) {
ExoPlayer.Builder(context)
@@ -167,7 +172,19 @@ fun VideoRecommendationItem(
},
modifier = Modifier
.fillMaxSize()
.noRippleClickable {
.pointerInput(videoUrl, moment.liked) {
detectTapGestures(
onDoubleTap = { offset ->
// 双击点赞/取消点赞
val currentTime = System.currentTimeMillis()
if (currentTime - lastDoubleTapTime.value > doubleTapDebounceTime) {
lastDoubleTapTime.value = currentTime
// 检查当前点赞状态,如果已点赞则取消点赞,如果未点赞则点赞
onLikeClick?.invoke(moment)
}
},
onTap = {
// 单击播放/暂停
pauseIconVisibleState = true
exoPlayer.pause()
scope.launch {
@@ -181,6 +198,8 @@ fun VideoRecommendationItem(
}
}
)
}
)
if (pauseIconVisibleState) {
Icon(
@@ -300,7 +319,9 @@ fun VideoRecommendationItem(
ModalBottomSheet(
onDismissRequest = { showCommentModal = false },
containerColor = Color.White,
sheetState = sheetState
sheetState = sheetState,
dragHandle = {},
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
) {
CommentModalContent(postId = moment.id) {
// 评论添加后的回调

View File

@@ -18,6 +18,9 @@ import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -273,6 +276,45 @@ fun GalleryGrid(
)
)
}
if (momentItem.images.size > 1 || (momentItem.videos?.size ?: 0) > 1) {
Box(
modifier = Modifier
.padding(top = 8.dp, end = 8.dp)
.align(Alignment.TopEnd)
) {
Image(
modifier = Modifier.size(24.dp),
painter = painterResource(R.drawable.rider_pro_picture_more),
contentDescription = "",
)
}
}
if (isVideoMoment) {
Box(
modifier = Modifier
.padding(top = 8.dp, end = 8.dp)
.align(Alignment.TopEnd)
) {
Box(
modifier = Modifier
.size(24.dp)
.background(
color = Color.Black.copy(alpha = 0.4f),
shape = RoundedCornerShape(12.dp)
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "",
tint = Color.White,
modifier = Modifier.size(16.dp)
)
}
}
}
}
}
}

View File

@@ -13,6 +13,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -56,6 +57,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@@ -467,6 +469,9 @@ fun VideoPlayer(
.clip(RectangleShape)
) {
var playerView by remember { mutableStateOf<PlayerView?>(null) }
// 防抖:记录上次双击时间,防止快速重复双击
val lastDoubleTapTime = remember { mutableStateOf(0L) }
val doubleTapDebounceTime = 500L // 500ms 防抖时间
// 使用 key 强制每个视频的 PlayerView 完全独立,避免布局状态残留
androidx.compose.runtime.key(videoUrl) {
@@ -479,11 +484,27 @@ fun VideoPlayer(
modifier = Modifier
.fillMaxSize()
.clip(RectangleShape)
.noRippleClickable {
.pointerInput(videoUrl, moment?.liked) {
detectTapGestures(
onDoubleTap = { offset ->
// 双击点赞/取消点赞
val currentTime = System.currentTimeMillis()
if (currentTime - lastDoubleTapTime.value > doubleTapDebounceTime) {
lastDoubleTapTime.value = currentTime
moment?.let {
// 检查当前点赞状态,如果已点赞则取消点赞,如果未点赞则点赞
onLikeClick?.invoke(it)
}
}
},
onTap = {
// 单击播放/暂停
handleVideoClick(pauseIconVisibleState, exoPlayer, scope)
}
)
}
)
}
if (pauseIconVisibleState.value) {
Icon(
@@ -660,7 +681,8 @@ fun VideoPlayer(
},
containerColor = AppColors.background,
sheetState = sheetState,
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
dragHandle = {},
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
) {
Box(
modifier = Modifier
@@ -670,6 +692,7 @@ fun VideoPlayer(
CommentModalContent(
postId = moment.id,
commentCount = moment.commentCount,
showTitle = false,
onCommentAdded = {
onCommentAdded?.invoke(moment)
}

View File

@@ -12,6 +12,8 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
@@ -1159,14 +1161,26 @@ fun ImageViewerDialog(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PostImageView(
images: List<MomentImageEntity>,
initialPage: Int? = 0
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var isImageViewerDialog by remember { mutableStateOf(false) }
var currentImageIndex by remember { mutableStateOf(initialPage ?: 0) }
val initialPageIndex = initialPage ?: 0
val pagerState = rememberPagerState(
pageCount = { images.size },
initialPage = initialPageIndex.coerceIn(0, maxOf(0, images.size - 1))
)
var currentImageIndex by remember { mutableStateOf(pagerState.currentPage) }
// 同步 pagerState 的当前页面到 currentImageIndex
LaunchedEffect(pagerState.currentPage) {
currentImageIndex = pagerState.currentPage
}
DisposableEffect(Unit) {
onDispose {
@@ -1187,14 +1201,21 @@ fun PostImageView(
modifier = Modifier
) {
if (images.isNotEmpty()) {
CustomAsyncImage(
context,
images[currentImageIndex].thumbnail,
contentDescription = "Image",
contentScale = ContentScale.Crop,
HorizontalPager(
state = pagerState,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) { page ->
val image = images[page]
CustomAsyncImage(
context,
image.thumbnail,
contentDescription = "Image",
blurHash = image.blurHash,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures(
onTap = {
@@ -1205,6 +1226,7 @@ fun PostImageView(
.background(Color.Gray.copy(alpha = 0.1f))
)
}
}
// 图片导航控件
if (images.size > 1) {
@@ -1212,33 +1234,17 @@ fun PostImageView(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
// Previous button
Text(
text = "Previous",
modifier = Modifier
.padding(8.dp)
.noRippleClickable {
if (currentImageIndex > 0) {
currentImageIndex--
}
},
color = if (currentImageIndex > 0) Color.Blue else Color.Gray
)
// Indicators
Row(
horizontalArrangement = Arrangement.Center
) {
images.forEachIndexed { index, _ ->
Box(
modifier = Modifier
.size(4.dp)
.clip(CircleShape)
.background(
if (currentImageIndex == index) Color.Red else Color.Gray.copy(
if (pagerState.currentPage == index) Color.Red else Color.Gray.copy(
alpha = 0.5f
)
)
@@ -1249,20 +1255,6 @@ fun PostImageView(
}
}
}
// Next button
Text(
text = "Next",
modifier = Modifier
.padding(8.dp)
.noRippleClickable {
if (currentImageIndex < images.size - 1) {
currentImageIndex++
}
},
color = if (currentImageIndex < images.size - 1) Color.Blue else Color.Gray
)
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 935 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 829 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 656 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 882 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 932 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 852 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 793 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 993 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Some files were not shown because too many files have changed in this diff Show More