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

This commit is contained in:
k1ngsterr1
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) >
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(
modifier =
Modifier.animateItemPlacement(
@@ -1644,6 +1665,8 @@ fun ChatDetailScreen(
isDarkTheme,
showTail =
showTail,
isGroupStart =
isGroupStart,
isSelected =
selectedMessages
.contains(

View File

@@ -96,6 +96,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val _opponentTyping = MutableStateFlow(false)
val opponentTyping: StateFlow<Boolean> = _opponentTyping.asStateFlow()
private var typingTimeoutJob: kotlinx.coroutines.Job? = null
// 🟢 Онлайн статус собеседника
private val _opponentOnline = MutableStateFlow(false)
@@ -357,6 +358,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
_messages.value = emptyList()
_opponentOnline.value = false
_opponentTyping.value = false
typingTimeoutJob?.cancel()
currentOffset = 0
hasMoreMessages = true
isLoadingMessages = false
@@ -401,6 +403,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
*/
fun closeDialog() {
isDialogActive = false
typingTimeoutJob?.cancel()
}
/**
@@ -2143,7 +2146,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private fun showTypingIndicator() {
_opponentTyping.value = true
viewModelScope.launch(Dispatchers.Default) {
// Отменяем предыдущий таймер, чтобы избежать race condition
typingTimeoutJob?.cancel()
typingTimeoutJob = viewModelScope.launch(Dispatchers.Default) {
kotlinx.coroutines.delay(3000)
_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.input.pointer.pointerInput
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -56,6 +59,82 @@ import kotlinx.coroutines.withContext
* 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 */
@Composable
fun DateHeader(dateText: String, secondaryTextColor: Color) {
@@ -138,6 +217,7 @@ fun MessageBubble(
message: ChatMessage,
isDarkTheme: Boolean,
showTail: Boolean = true,
isGroupStart: Boolean = false,
isSelected: Boolean = false,
isHighlighted: Boolean = false,
isSavedMessages: Boolean = false,
@@ -288,11 +368,15 @@ fun MessageBubble(
val combinedBackgroundColor =
if (isSelected) selectionBackgroundColor else highlightBackgroundColor
// Динамические отступы: больше между группами, меньше внутри группы
val topPadding = if (isGroupStart) 8.dp else 2.dp
val bottomPadding = 2.dp
Row(
modifier =
Modifier.fillMaxWidth()
.background(combinedBackgroundColor)
.padding(vertical = 2.dp)
.padding(top = topPadding, bottom = bottomPadding)
.offset { IntOffset(animatedOffset.toInt(), 0) },
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
@@ -438,142 +522,131 @@ fun MessageBubble(
)
}
// 🖼️ Caption под фото (как в Telegram)
// 🖼️ Caption под фото (как в Telegram) - Telegram-style layout
if (hasImageWithCaption) {
Column(
Box(
modifier =
Modifier.fillMaxWidth()
.background(bubbleColor)
.padding(
horizontal = 10.dp,
vertical = 6.dp
) // Уменьшил padding
.padding(horizontal = 10.dp, vertical = 6.dp)
) {
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
AppleEmojiText(
text = message.text,
color = textColor,
fontSize = 16.sp,
modifier = Modifier.weight(1f, fill = false)
)
Spacer(modifier = Modifier.width(8.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
TelegramStyleMessageContent(
textContent = {
AppleEmojiText(
text = message.text,
color = textColor,
fontSize = 16.sp
)
}
}
}
},
timeContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(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 - текст слева, время справа на одной строке
// Если есть reply - Telegram-style layout
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
TelegramStyleMessageContent(
textContent = {
AppleEmojiText(
text = message.text,
color = textColor,
fontSize = 17.sp
)
},
timeContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(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()) {
// Без reply, не только фото, и не фото с caption - компактно в одну строку
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.wrapContentWidth()
) {
AppleEmojiText(
text = message.text,
color = textColor,
fontSize = 17.sp,
modifier = Modifier.wrapContentWidth()
)
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
// Telegram-style: текст + время с автоматическим переносом
TelegramStyleMessageContent(
textContent = {
AppleEmojiText(
text = message.text,
color = textColor,
fontSize = 17.sp
)
},
timeContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(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
)
}
}
}
}
}
)
}
}
}