feat: enhance avatar expansion and collapse animations with dynamic timing and haptic feedback in ProfileScreen

This commit is contained in:
2026-02-01 18:59:22 +05:00
parent 5b983b4a89
commit 1e9860a221
3 changed files with 221 additions and 120 deletions

View File

@@ -1612,6 +1612,27 @@ fun ChatDetailScreen(
.time) > .time) >
60_000 60_000
// Определяем начало новой
// группы (для отступов)
val prevMessage =
messagesWithDates
.getOrNull(
index -
1
)
?.first
val isGroupStart =
prevMessage != null &&
(prevMessage
.isOutgoing !=
message.isOutgoing ||
(prevMessage
.timestamp
.time -
message.timestamp
.time) >
60_000)
Column( Column(
modifier = modifier =
Modifier.animateItemPlacement( Modifier.animateItemPlacement(
@@ -1644,6 +1665,8 @@ fun ChatDetailScreen(
isDarkTheme, isDarkTheme,
showTail = showTail =
showTail, showTail,
isGroupStart =
isGroupStart,
isSelected = isSelected =
selectedMessages selectedMessages
.contains( .contains(

View File

@@ -96,6 +96,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val _opponentTyping = MutableStateFlow(false) private val _opponentTyping = MutableStateFlow(false)
val opponentTyping: StateFlow<Boolean> = _opponentTyping.asStateFlow() val opponentTyping: StateFlow<Boolean> = _opponentTyping.asStateFlow()
private var typingTimeoutJob: kotlinx.coroutines.Job? = null
// 🟢 Онлайн статус собеседника // 🟢 Онлайн статус собеседника
private val _opponentOnline = MutableStateFlow(false) private val _opponentOnline = MutableStateFlow(false)
@@ -357,6 +358,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
_messages.value = emptyList() _messages.value = emptyList()
_opponentOnline.value = false _opponentOnline.value = false
_opponentTyping.value = false _opponentTyping.value = false
typingTimeoutJob?.cancel()
currentOffset = 0 currentOffset = 0
hasMoreMessages = true hasMoreMessages = true
isLoadingMessages = false isLoadingMessages = false
@@ -401,6 +403,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
*/ */
fun closeDialog() { fun closeDialog() {
isDialogActive = false isDialogActive = false
typingTimeoutJob?.cancel()
} }
/** /**
@@ -2143,7 +2146,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private fun showTypingIndicator() { private fun showTypingIndicator() {
_opponentTyping.value = true _opponentTyping.value = true
viewModelScope.launch(Dispatchers.Default) { // Отменяем предыдущий таймер, чтобы избежать race condition
typingTimeoutJob?.cancel()
typingTimeoutJob = viewModelScope.launch(Dispatchers.Default) {
kotlinx.coroutines.delay(3000) kotlinx.coroutines.delay(3000)
_opponentTyping.value = false _opponentTyping.value = false
} }

View File

@@ -28,6 +28,9 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -56,6 +59,82 @@ import kotlinx.coroutines.withContext
* organization * organization
*/ */
/**
* Telegram-style layout для текста сообщения с временем.
* Если текст + время помещаются в одну строку - располагает их рядом.
* Если текст длинный и переносится - время встаёт в правый нижний угол,
* под последней строкой текста (как в Telegram).
*
* @param textContent Composable с текстом сообщения
* @param timeContent Composable с временем и статусом
* @param modifier Модификатор для Layout
* @param spacing Минимальный отступ между текстом и временем (dp)
*/
@Composable
fun TelegramStyleMessageContent(
textContent: @Composable () -> Unit,
timeContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
spacing: Int = 10
) {
val density = LocalDensity.current
val spacingPx = with(density) { spacing.dp.roundToPx() }
val newLineHeightPx = with(density) { 14.dp.roundToPx() }
Layout(
content = {
textContent()
timeContent()
},
modifier = modifier
) { measurables, constraints ->
require(measurables.size == 2) { "TelegramStyleMessageContent requires exactly 2 children" }
val textMeasurable = measurables[0]
val timeMeasurable = measurables[1]
// Измеряем время с минимальными constraints
val timePlaceable = timeMeasurable.measure(constraints.copy(minWidth = 0, minHeight = 0))
// Измеряем текст с полной доступной шириной
val textConstraints = constraints.copy(minWidth = 0, minHeight = 0)
val textPlaceable = textMeasurable.measure(textConstraints)
// Проверяем, помещается ли текст + время в одну строку
val textWidth = textPlaceable.width
val timeWidth = timePlaceable.width
val totalSingleLineWidth = textWidth + spacingPx + timeWidth
// Если текст занимает всю ширину (с учётом constraints), значит он переносится
val textWraps = textWidth >= constraints.maxWidth - timeWidth - spacingPx
// Определяем layout
val (width, height, timeX, timeY) =
if (!textWraps && totalSingleLineWidth <= constraints.maxWidth) {
// Текст и время на одной строке
val w = totalSingleLineWidth
val h = maxOf(textPlaceable.height, timePlaceable.height)
val tX = textWidth + spacingPx
val tY = h - timePlaceable.height // Время внизу строки
LayoutResult(w, h, tX, tY)
} else {
// Текст переносится - время справа внизу на новой строке
val w = maxOf(textWidth, timeWidth)
val h = textPlaceable.height + newLineHeightPx
val tX = w - timeWidth // Время справа
val tY = h - timePlaceable.height // Время внизу
LayoutResult(w, h, tX, tY)
}
layout(width, height) {
textPlaceable.placeRelative(0, 0)
timePlaceable.placeRelative(timeX, timeY)
}
}
}
private data class LayoutResult(val width: Int, val height: Int, val timeX: Int, val timeY: Int)
/** Date header with fade-in animation */ /** Date header with fade-in animation */
@Composable @Composable
fun DateHeader(dateText: String, secondaryTextColor: Color) { fun DateHeader(dateText: String, secondaryTextColor: Color) {
@@ -138,6 +217,7 @@ fun MessageBubble(
message: ChatMessage, message: ChatMessage,
isDarkTheme: Boolean, isDarkTheme: Boolean,
showTail: Boolean = true, showTail: Boolean = true,
isGroupStart: Boolean = false,
isSelected: Boolean = false, isSelected: Boolean = false,
isHighlighted: Boolean = false, isHighlighted: Boolean = false,
isSavedMessages: Boolean = false, isSavedMessages: Boolean = false,
@@ -288,11 +368,15 @@ fun MessageBubble(
val combinedBackgroundColor = val combinedBackgroundColor =
if (isSelected) selectionBackgroundColor else highlightBackgroundColor if (isSelected) selectionBackgroundColor else highlightBackgroundColor
// Динамические отступы: больше между группами, меньше внутри группы
val topPadding = if (isGroupStart) 8.dp else 2.dp
val bottomPadding = 2.dp
Row( Row(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.background(combinedBackgroundColor) .background(combinedBackgroundColor)
.padding(vertical = 2.dp) .padding(top = topPadding, bottom = bottomPadding)
.offset { IntOffset(animatedOffset.toInt(), 0) }, .offset { IntOffset(animatedOffset.toInt(), 0) },
horizontalArrangement = Arrangement.Start, horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -438,35 +522,71 @@ fun MessageBubble(
) )
} }
// 🖼️ Caption под фото (как в Telegram) // 🖼️ Caption под фото (как в Telegram) - Telegram-style layout
if (hasImageWithCaption) { if (hasImageWithCaption) {
Column( Box(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.background(bubbleColor) .background(bubbleColor)
.padding( .padding(horizontal = 10.dp, vertical = 6.dp)
horizontal = 10.dp,
vertical = 6.dp
) // Уменьшил padding
) {
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) { ) {
TelegramStyleMessageContent(
textContent = {
AppleEmojiText( AppleEmojiText(
text = message.text, text = message.text,
color = textColor, color = textColor,
fontSize = 16.sp, fontSize = 16.sp
modifier = Modifier.weight(1f, fill = false)
) )
},
Spacer(modifier = Modifier.width(8.dp)) timeContent = {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp), horizontalArrangement = Arrangement.spacedBy(2.dp)
modifier = Modifier.padding(bottom = 2.dp) ) {
Text(
text = timeFormat.format(message.timestamp),
color = timeColor,
fontSize = 11.sp,
fontStyle =
androidx.compose.ui.text.font.FontStyle
.Italic
)
if (message.isOutgoing) {
val displayStatus =
if (isSavedMessages) MessageStatus.READ
else message.status
AnimatedMessageStatus(
status = displayStatus,
timeColor = timeColor,
timestamp = message.timestamp.time,
onRetry = onRetry,
onDelete = onDelete
)
}
}
},
spacing = 8
)
}
} else if (message.attachments.isNotEmpty() && message.text.isNotEmpty()) {
// Обычное фото + текст (не только изображения)
Spacer(modifier = Modifier.height(8.dp))
}
// Если есть reply - Telegram-style layout
if (message.replyData != null && message.text.isNotEmpty()) {
TelegramStyleMessageContent(
textContent = {
AppleEmojiText(
text = message.text,
color = textColor,
fontSize = 17.sp
)
},
timeContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
Text( Text(
text = timeFormat.format(message.timestamp), text = timeFormat.format(message.timestamp),
@@ -489,76 +609,28 @@ fun MessageBubble(
} }
} }
} }
}
} else if (message.attachments.isNotEmpty() && message.text.isNotEmpty()) {
// Обычное фото + текст (не только изображения)
Spacer(modifier = Modifier.height(8.dp))
}
// Если есть reply - текст слева, время справа на одной строке
if (message.replyData != null && message.text.isNotEmpty()) {
Row(
verticalAlignment = Alignment.Bottom,
modifier = Modifier.fillMaxWidth()
) {
AppleEmojiText(
text = message.text,
color = textColor,
fontSize = 17.sp,
modifier = Modifier.weight(1f, fill = false)
) )
Spacer(modifier = Modifier.width(10.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.padding(bottom = 2.dp)
) {
Text(
text = timeFormat.format(message.timestamp),
color = timeColor,
fontSize = 11.sp,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
)
if (message.isOutgoing) {
val displayStatus =
if (isSavedMessages) MessageStatus.READ
else message.status
AnimatedMessageStatus(
status = displayStatus,
timeColor = timeColor,
timestamp = message.timestamp.time,
onRetry = onRetry,
onDelete = onDelete
)
}
}
}
} else if (!hasOnlyMedia && !hasImageWithCaption && message.text.isNotEmpty()) { } else if (!hasOnlyMedia && !hasImageWithCaption && message.text.isNotEmpty()) {
// Без reply, не только фото, и не фото с caption - компактно в одну строку // Telegram-style: текст + время с автоматическим переносом
Row( TelegramStyleMessageContent(
verticalAlignment = Alignment.Bottom, textContent = {
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.wrapContentWidth()
) {
AppleEmojiText( AppleEmojiText(
text = message.text, text = message.text,
color = textColor, color = textColor,
fontSize = 17.sp, fontSize = 17.sp
modifier = Modifier.wrapContentWidth()
) )
},
timeContent = {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp), horizontalArrangement = Arrangement.spacedBy(2.dp)
modifier = Modifier.padding(bottom = 2.dp)
) { ) {
Text( Text(
text = timeFormat.format(message.timestamp), text = timeFormat.format(message.timestamp),
color = timeColor, color = timeColor,
fontSize = 11.sp, fontSize = 11.sp,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic fontStyle =
androidx.compose.ui.text.font.FontStyle.Italic
) )
if (message.isOutgoing) { if (message.isOutgoing) {
val displayStatus = val displayStatus =
@@ -574,6 +646,7 @@ fun MessageBubble(
} }
} }
} }
)
} }
} }
} }