Синхронизированы анимации чата и Requests с Telegram, добавлен shimmer и исправлено копирование текста

This commit is contained in:
2026-03-18 20:09:33 +05:00
parent 74d5db3f05
commit 5d8984ab91
4 changed files with 260 additions and 61 deletions

View File

@@ -17,8 +17,13 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.SizeTransform
@@ -53,6 +58,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@@ -119,6 +125,8 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private val groupMembersCountCache = java.util.concurrent.ConcurrentHashMap<String, Int>()
private data class IncomingRunAvatarAccumulator(
val senderPublicKey: String,
val senderDisplayName: String,
@@ -515,11 +523,15 @@ fun ChatDetailScreen(
val chatsListViewModel: ChatsListViewModel = viewModel()
val dialogsList by chatsListViewModel.dialogs.collectAsState()
val groupRepository = remember { GroupRepository.getInstance(context) }
val groupMembersCacheKey =
remember(user.publicKey, currentUserPublicKey) {
"${currentUserPublicKey.trim()}::${user.publicKey.trim()}"
}
var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) {
mutableStateOf<Set<String>>(emptySet())
}
var groupMembersCount by remember(user.publicKey, currentUserPublicKey) {
mutableStateOf<Int?>(null)
var groupMembersCount by remember(groupMembersCacheKey) {
mutableStateOf<Int?>(groupMembersCountCache[groupMembersCacheKey])
}
var mentionCandidates by remember(user.publicKey, currentUserPublicKey) {
mutableStateOf<List<MentionCandidate>>(emptyList())
@@ -540,14 +552,25 @@ fun ChatDetailScreen(
return@LaunchedEffect
}
val cachedMembersCount = groupMembersCountCache[groupMembersCacheKey]
groupMembersCount = cachedMembersCount
val members = withContext(Dispatchers.IO) {
groupRepository.requestGroupMembers(user.publicKey).orEmpty()
groupRepository.requestGroupMembers(user.publicKey)
}
if (members == null) {
groupAdminKeys = emptySet()
mentionCandidates = emptyList()
return@LaunchedEffect
}
val normalizedMembers =
members.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
groupMembersCount = normalizedMembers.size
if (normalizedMembers.isNotEmpty()) {
groupMembersCountCache[groupMembersCacheKey] = normalizedMembers.size
}
val adminKey = normalizedMembers.firstOrNull().orEmpty()
groupAdminKeys =
@@ -1006,9 +1029,13 @@ fun ChatDetailScreen(
val isRosettaOfficial = user.title.equals("Rosetta", ignoreCase = true) ||
user.username.equals("rosetta", ignoreCase = true) ||
isSystemAccount
val groupMembersSubtitleCount = groupMembersCount ?: 0
val groupMembersSubtitleCount = groupMembersCount
val groupMembersSubtitle =
"$groupMembersSubtitleCount member${if (groupMembersSubtitleCount == 1) "" else "s"}"
if (groupMembersSubtitleCount == null) {
""
} else {
"$groupMembersSubtitleCount member${if (groupMembersSubtitleCount == 1) "" else "s"}"
}
val chatSubtitle =
when {
isSavedMessages -> "Notes"
@@ -1249,19 +1276,10 @@ fun ChatDetailScreen(
extractCopyableMessageText(
msg
)
if (messageText.isBlank()) {
null
} else {
val time =
SimpleDateFormat(
"HH:mm",
Locale.getDefault()
)
.format(
msg.timestamp
)
"[${if (msg.isOutgoing) "You" else chatTitle}] $time\n$messageText"
}
messageText
.takeIf {
it.isNotBlank()
}
}
.joinToString(
"\n\n"
@@ -1582,6 +1600,10 @@ fun ChatDetailScreen(
isDarkTheme =
isDarkTheme
)
} else if (isGroupChat &&
groupMembersCount == null
) {
GroupMembersSubtitleSkeleton()
} else {
Text(
text =
@@ -3597,6 +3619,53 @@ fun ChatDetailScreen(
} // Закрытие outer Box
}
@Composable
private fun GroupMembersSubtitleSkeleton() {
val transition = rememberInfiniteTransition(label = "groupMembersSkeleton")
val shimmerProgress by
transition.animateFloat(
initialValue = -1f,
targetValue = 2f,
animationSpec =
infiniteRepeatable(
animation = tween(1200, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "groupMembersSkeletonShimmer"
)
BoxWithConstraints(
modifier =
Modifier.padding(top = 2.dp)
.width(76.dp)
.height(12.dp)
) {
val density = LocalDensity.current
val widthPx = with(density) { maxWidth.toPx() }.coerceAtLeast(1f)
val gradientWidthPx = with(density) { 44.dp.toPx() }
val shimmerX = shimmerProgress * widthPx
val shimmerBrush =
Brush.linearGradient(
colorStops =
arrayOf(
0f to Color.White.copy(alpha = 0.22f),
0.5f to Color.White.copy(alpha = 0.56f),
1f to Color.White.copy(alpha = 0.22f)
),
start = Offset(shimmerX - gradientWidthPx, 0f),
end = Offset(shimmerX, 0f)
)
Box(
modifier =
Modifier.fillMaxSize()
.clip(RoundedCornerShape(6.dp))
.background(shimmerBrush)
)
}
}
@Composable
private fun TiledChatWallpaper(
wallpaperResId: Int,

View File

@@ -2171,39 +2171,102 @@ fun ChatsListScreen(
}
}
if (requestsCount > 0) {
item(
key =
"requests_section"
item(key = "requests_section") {
val isRequestsSectionVisible =
requestsCount > 0 &&
isRequestsVisible
AnimatedVisibility(
visible =
isRequestsSectionVisible,
enter =
slideInVertically(
initialOffsetY = {
fullHeight ->
-fullHeight /
3
},
animationSpec =
tween(
durationMillis =
260,
easing =
FastOutSlowInEasing
)
) +
expandVertically(
expandFrom =
Alignment
.Top,
animationSpec =
tween(
durationMillis =
260,
easing =
FastOutSlowInEasing
)
) +
fadeIn(
animationSpec =
tween(
durationMillis =
180
),
initialAlpha =
0.7f
),
exit =
slideOutVertically(
targetOffsetY = {
fullHeight ->
-fullHeight /
3
},
animationSpec =
tween(
durationMillis =
220,
easing =
FastOutSlowInEasing
)
) +
shrinkVertically(
shrinkTowards =
Alignment
.Top,
animationSpec =
tween(
durationMillis =
220,
easing =
FastOutSlowInEasing
)
) +
fadeOut(
animationSpec =
tween(
durationMillis =
140
)
)
) {
AnimatedVisibility(
visible = isRequestsVisible,
enter = expandVertically(
animationSpec = tween(250, easing = FastOutSlowInEasing)
) + fadeIn(animationSpec = tween(200)),
exit = shrinkVertically(
animationSpec = tween(250, easing = FastOutSlowInEasing)
) + fadeOut(animationSpec = tween(200))
) {
Column {
RequestsSection(
count =
requestsCount,
requests =
requests,
isDarkTheme =
isDarkTheme,
onClick = {
openRequestsRouteSafely()
}
)
Divider(
color =
dividerColor,
thickness =
0.5.dp
)
}
Column {
RequestsSection(
count =
requestsCount,
requests =
requests,
isDarkTheme =
isDarkTheme,
onClick = {
openRequestsRouteSafely()
}
)
Divider(
color =
dividerColor,
thickness =
0.5.dp
)
}
}
}