Compare commits

...

24 Commits

Author SHA1 Message Date
6e14213b5c Релиз 1.2.3: обновлены версия и release notes
All checks were successful
Android Kernel Build / build (push) Successful in 24m33s
2026-03-19 15:32:09 +05:00
af4a3a5f27 Синхронизация статусов и времени существующих сообщений, добавление новых сообщений в кэш
Some checks failed
Android Kernel Build / build (push) Has been cancelled
2026-03-19 15:29:09 +05:00
9a411ac473 Исправлены аватар в сайдбаре и медиа-пузыри в группах 2026-03-19 15:28:44 +05:00
75d0f4726b Фикс галочки в сайдбаре
All checks were successful
Android Kernel Build / build (push) Successful in 1h6m55s
2026-03-19 07:21:22 +05:00
581a44b270 Слит dev в master: изменения после 1.2.1 и обновлены release notes
All checks were successful
Android Kernel Build / build (push) Successful in 1h10m41s
2026-03-18 23:55:19 +05:00
cd325bea87 Бамп версии до 1.2.2 и обновление release notes после 1.2.1
Some checks failed
Android Kernel Build / build (push) Has been cancelled
2026-03-18 23:41:11 +05:00
b918b45603 Сделан интерактивный drag медиа-пикеров и обновлены release notes
Some checks failed
Android Kernel Build / build (push) Has been cancelled
2026-03-18 23:35:38 +05:00
5e66437239 Bump version to 1.2.1 and increment version code to 23
All checks were successful
Android Kernel Build / build (push) Successful in 16h11m23s
2026-03-17 15:36:11 +07:00
670093c8fe Merge dev into master (resolve conflicts: keep dev release notes)
All checks were successful
Android Kernel Build / build (push) Successful in 17h43m23s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 00:00:27 +07:00
c2198b624d Фикс цвет галочек
Some checks are pending
Android Kernel Build / build (push) Waiting to run
2026-03-16 15:20:53 +07:00
807309a812 Скрытие клавиатуры при свайпе назад на экране поиска
Some checks failed
Android Kernel Build / build (push) Failing after 16h11m23s
Добавлена обработка горизонтального свайпа вправо для автоматического скрытия клавиатуры на SearchScreen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:36:06 +07:00
479fdd0074 Обновлены release notes: добавлены новые функции и исправления интерфейса
All checks were successful
Android Kernel Build / build (push) Successful in 16h27m5s
2026-03-16 01:08:38 +07:00
b1d4458484 Добавлены release notes для версии 1.2.0 (с 1.1.9) 2026-03-16 01:05:54 +07:00
b5398e2f1d Релиз 1.2.0: синхронизированы статусы, скролл и UI-выравнивание
All checks were successful
Android Kernel Build / build (push) Successful in 17h2m4s
- Поднята версия приложения до 1.2.0 (versionCode 22)\n- Синхронизированы статусы отправки между чат-листом и диалогом: SENT отображается как часы до delivery, ERROR теперь стабильно приходит в открытый диалог\n- Доработан Telegram-подобный skeleton в диалоге: shimmer, геометрия пузырей, поддержка групповых аватаров\n- Добавлен плавный автоскролл к баннеру подтверждения нового устройства в чат-листе\n- Выровнены verified-галочки с именами в профилях и в сайдбаре\n- Кнопка Copy Seed Phrase в светлой теме приведена к белому тексту\n- Мелкие UI-правки в чате и компонентах ввода/эмодзи
2026-03-16 00:02:27 +07:00
bae665f89d Merge branch 'dev'
All checks were successful
Android Kernel Build / build (push) Successful in 16h26m43s
2026-03-14 22:04:49 +07:00
a5ec0595ad Синхронизирована логика read-индикаторов в диалоге с чат-листом
All checks were successful
Android Kernel Build / build (push) Successful in 16h17m58s
2026-03-14 21:17:21 +07:00
d78fb184c6 Скрыт инпут и оверлеи при рисовании на фото
All checks were successful
Android Kernel Build / build (push) Successful in 16h20m6s
2026-03-14 15:10:46 +07:00
ddd98a8065 Исправлен черный gesture navigation bar при fullscreen фото
All checks were successful
Android Kernel Build / build (push) Successful in 16h12m7s
2026-03-14 14:24:25 +07:00
d9c54b2d05 Исправлен цвет галочки верификации в профилях и попапах по теме
All checks were successful
Android Kernel Build / build (push) Successful in 16h18m46s
2026-03-14 01:18:20 +07:00
494b459e39 Исправлен цвет галочки верификации в сайдбаре по теме
Some checks failed
Android Kernel Build / build (push) Has been cancelled
2026-03-14 01:15:43 +07:00
8c2e30b4d8 Merge branch 'dev'
Some checks failed
Android Kernel Build / build (push) Has been cancelled
2026-03-14 01:05:19 +07:00
0aa34e75c9 Выпуск 1.1.7: слияние dev в master
All checks were successful
Android Kernel Build / build (push) Successful in 16h25m22s
2026-03-11 23:03:48 +07:00
43bcfdff1b Выпуск 1.1.6: слияние dev в master
All checks were successful
Android Kernel Build / build (push) Successful in 16h13m12s
2026-03-10 23:25:16 +05:00
982dfc5dff Релиз 1.1.5: ускорено подключение и исправлены группы
All checks were successful
Android Kernel Build / build (push) Successful in 16h12m4s
2026-03-09 20:20:54 +05:00
10 changed files with 277 additions and 154 deletions

View File

@@ -1,5 +1,16 @@
# Release Notes
## 1.2.3
### Групповые чаты и медиа
- Исправлено отображение групповых баблов: логика стеков и аватаров приведена ближе к desktop-версии.
- Исправлено позиционирование аватарки в группе: аватар и имя теперь отображаются на одном сообщении (без «разъезда»).
- Исправлена обрезка имени отправителя в медиа-баблах группового чата.
- Исправлено растяжение и кривые пропорции фото в forwarded/media-пузырях.
### Sidebar
- Убрана лишняя рамка (border) вокруг аватарки в сайдбаре.
## 1.2.1
### Синхронизация Android ↔ iOS

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.2.1"
val rosettaVersionCode = 23 // Increment on each release
val rosettaVersionName = "1.2.3"
val rosettaVersionCode = 25 // Increment on each release
android {
namespace = "com.rosetta.messenger"

View File

@@ -17,14 +17,16 @@ object ReleaseNotes {
val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER
Синхронизация Android ↔ iOS
- Исправлена проблема: сообщения зависали на «часиках» при одновременном использовании Android и iOS
- Добавлен механизм автоматического повтора отправки: 3 попытки с интервалом 4 сек, таймаут 80 сек
- Исправлена нормализация sync-курсора для корректной синхронизации между устройствами
Что обновлено после версии 1.2.2
Группы и медиа
- Исправлено отображение групповых баблов и стеков сообщений
- Исправлено позиционирование аватарки: имя и аватар в группе теперь не разъезжаются
- Исправлена обрезка имени отправителя в медиа-баблах
- Исправлено растяжение фото в forwarded/media-пузырях
Интерфейс
- Дата «today/yesterday» и пустой стейт чата теперь белые при тёмных обоях или тёмной теме
- Исправлена обрезка имени отправителя в групповых чатах
- Убрана лишняя рамка вокруг аватарки в боковом меню
""".trimIndent()
fun getNotice(version: String): String =

View File

@@ -126,6 +126,25 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private val groupMembersCountCache = java.util.concurrent.ConcurrentHashMap<String, Int>()
private const val GROUP_MESSAGE_STACK_TIME_DIFF_MS = 5 * 60_000L
private const val DIRECT_MESSAGE_STACK_TIME_DIFF_MS = 60_000L
private fun isSameLocalDay(firstTimestampMs: Long, secondTimestampMs: Long): Boolean {
val firstCalendar =
java.util.Calendar.getInstance().apply {
timeInMillis = firstTimestampMs
}
val secondCalendar =
java.util.Calendar.getInstance().apply {
timeInMillis = secondTimestampMs
}
return firstCalendar.get(java.util.Calendar.ERA) ==
secondCalendar.get(java.util.Calendar.ERA) &&
firstCalendar.get(java.util.Calendar.YEAR) ==
secondCalendar.get(java.util.Calendar.YEAR) &&
firstCalendar.get(java.util.Calendar.DAY_OF_YEAR) ==
secondCalendar.get(java.util.Calendar.DAY_OF_YEAR)
}
private data class IncomingRunAvatarAccumulator(
val senderPublicKey: String,
@@ -777,16 +796,26 @@ fun ChatDetailScreen(
}
val isMessageBoundary: (ChatMessage, ChatMessage?) -> Boolean =
remember(isGroupChat, currentUserPublicKey, user.publicKey) {
val maxStackTimeDiffMs =
if (isGroupChat) {
GROUP_MESSAGE_STACK_TIME_DIFF_MS
} else {
DIRECT_MESSAGE_STACK_TIME_DIFF_MS
}
{ currentMessage, adjacentMessage ->
if (adjacentMessage == null) {
true
} else if (adjacentMessage.isOutgoing != currentMessage.isOutgoing) {
true
} else if (
kotlin.math.abs(
currentMessage.timestamp.time -
adjacentMessage.timestamp.time
) > 60_000L
!isSameLocalDay(
currentMessage.timestamp.time,
adjacentMessage.timestamp.time
) ||
kotlin.math.abs(
currentMessage.timestamp.time -
adjacentMessage.timestamp.time
) > maxStackTimeDiffMs
) {
true
} else if (
@@ -1739,14 +1768,18 @@ fun ChatDetailScreen(
} // Закрытие Crossfade
// Bottom line для unified header
val headerDividerColor =
when {
showMediaPicker -> Color.Transparent
isDarkTheme -> Color.Black.copy(alpha = 0.26f)
else -> Color.White.copy(alpha = 0.15f)
}
Box(
modifier =
Modifier.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(0.5.dp)
.background(
Color.White.copy(alpha = 0.15f)
)
.background(headerDividerColor)
)
} // Закрытие Box unified header
@@ -2527,41 +2560,12 @@ fun ChatDetailScreen(
isMessageBoundary(message, prevMessage)
val isGroupStart =
isMessageBoundary(message, nextMessage)
val runHeadIndex =
messageRunNewestIndex.getOrNull(
index
) ?: index
val runTailIndex =
messageRunOldestIndexByHead
.getOrNull(
runHeadIndex
)
?: runHeadIndex
val isHeadPhase =
incomingRunAvatarUiState
.showOnRunHeads
.contains(
runHeadIndex
)
val isTailPhase =
incomingRunAvatarUiState
.showOnRunTails
.contains(
runHeadIndex
)
val showIncomingGroupAvatar =
isGroupChat &&
!message.isOutgoing &&
senderPublicKeyForMessage
.isNotBlank() &&
((index ==
runHeadIndex &&
isHeadPhase &&
showTail) ||
(index ==
runTailIndex &&
isTailPhase &&
isGroupStart))
isGroupStart
Column {
if (showDate

View File

@@ -902,37 +902,60 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔥 Берём ТЕКУЩЕЕ состояние UI (может уже содержать новые сообщения)
val currentMessages = _messages.value
val existingIds = currentMessages.map { it.id }.toSet()
val entitiesById = entities.associateBy { it.messageId }
// 🔄 Синхронизируем статусы/время уже существующих сообщений из БД.
// Это критично для случая WAITING -> ERROR: без этого в открытом диалоге остаются "часики".
val reconciledMessages =
currentMessages.map { message ->
val entity = entitiesById[message.id] ?: return@map message
val dbStatus =
when (entity.delivered) {
0 -> MessageStatus.SENDING
1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
2 -> MessageStatus.ERROR
3 -> MessageStatus.READ
else -> MessageStatus.SENT
}
var updatedMessage = message
if (updatedMessage.status != dbStatus) {
updatedMessage = updatedMessage.copy(status = dbStatus)
}
if (updatedMessage.timestamp.time != entity.timestamp) {
updatedMessage = updatedMessage.copy(timestamp = Date(entity.timestamp))
}
updatedMessage
}
val hasExistingUpdates = reconciledMessages != currentMessages
// 🔥 Находим только НОВЫЕ сообщения (которых нет в текущем UI)
val newEntities = entities.filter { it.messageId !in existingIds }
if (newEntities.isNotEmpty()) {
val semaphore = Semaphore(DECRYPT_PARALLELISM)
val newMessages = coroutineScope {
newEntities
.map { entity ->
async { semaphore.withPermit { entityToChatMessage(entity) } }
}
.awaitAll()
}
val newMessages =
if (newEntities.isNotEmpty()) {
val semaphore = Semaphore(DECRYPT_PARALLELISM)
coroutineScope {
newEntities
.map { entity ->
async { semaphore.withPermit { entityToChatMessage(entity) } }
}
.awaitAll()
}
} else {
emptyList()
}
// 🔥 ДОБАВЛЯЕМ новые к текущим, а не заменяем!
// Сортируем по timestamp чтобы новые были в конце
if (hasExistingUpdates || newMessages.isNotEmpty()) {
// 🔥 ДОБАВЛЯЕМ новые + применяем статусные апдейты к существующим
val updatedMessages =
sortMessagesAscending((currentMessages + newMessages).distinctBy { it.id })
sortMessagesAscending((reconciledMessages + newMessages).distinctBy { it.id })
// 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш сохраняя ВСЕ сообщения, не только отображаемые!
// Объединяем существующий кэш с новыми сообщениями
// 🔥 Обновляем кэш: сохраняем старые страницы + применяем новые статусы/время
val existingCache = dialogMessagesCache[cacheKey(account, dialogKey)] ?: emptyList()
val allCachedIds = existingCache.map { it.id }.toSet()
val trulyNewMessages = newMessages.filter { it.id !in allCachedIds }
if (trulyNewMessages.isNotEmpty()) {
updateCacheWithLimit(
account,
dialogKey,
sortMessagesAscending(existingCache + trulyNewMessages)
)
}
val mergedCache = sortMessagesAscending((existingCache + updatedMessages).distinctBy { it.id })
updateCacheWithLimit(account, dialogKey, mergedCache)
withContext(Dispatchers.Main.immediate) { _messages.value = updatedMessages }
}

View File

@@ -834,10 +834,6 @@ fun ChatsListScreen(
modifier =
Modifier.size(72.dp)
.clip(CircleShape)
.background(
Color.White
.copy(alpha = 0.2f)
)
.combinedClickable(
onClick = {
scope.launch {
@@ -858,8 +854,7 @@ fun ChatsListScreen(
accountPublicKey
}
}
)
.padding(3.dp),
),
contentAlignment =
Alignment.Center
) {
@@ -868,7 +863,7 @@ fun ChatsListScreen(
accountPublicKey,
avatarRepository =
avatarRepository,
size = 66.dp,
size = 72.dp,
isDarkTheme =
isDarkTheme,
displayName =
@@ -928,27 +923,24 @@ fun ChatsListScreen(
// Display name
if (accountName.isNotEmpty()) {
Row(
horizontalArrangement = Arrangement.Start
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = accountName,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
modifier = Modifier.alignByBaseline()
color = Color.White
)
if (accountVerified > 0 || isRosettaOfficial || isFreddyOfficial) {
Box(
modifier =
Modifier.padding(start = 2.dp)
.alignBy { (it.measuredHeight * 0.78f).toInt() }
) {
VerifiedBadge(
verified = if (accountVerified > 0) accountVerified else 1,
size = 15,
badgeTint = if (isDarkTheme) PrimaryBlue else Color.White
)
}
Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge(
verified = if (accountVerified > 0) accountVerified else 1,
size = 15,
modifier = Modifier.offset(y = 1.dp),
isDarkTheme = isDarkTheme,
badgeTint = if (isDarkTheme) PrimaryBlue else Color.White
)
}
}
}

View File

@@ -47,7 +47,6 @@ import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.utils.NavigationModeUtils
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import android.net.Uri
import android.util.Log
@@ -352,6 +351,12 @@ fun ChatAttachAlert(
)
val sheetHeightPx = remember { Animatable(collapsedHeightPx) }
var dragSheetHeightPx by remember { mutableFloatStateOf(Float.NaN) }
val interactiveSheetHeightPx by remember {
derivedStateOf {
if (dragSheetHeightPx.isNaN()) sheetHeightPx.value else dragSheetHeightPx
}
}
val animationScope = rememberCoroutineScope()
// ═══════════════════════════════════════════════════════════
@@ -409,6 +414,7 @@ fun ChatAttachAlert(
val action = closeAction
closeAction = null
animationScope.launch {
dragSheetHeightPx = Float.NaN
sheetHeightPx.snapTo(collapsedHeightPx)
}
action?.invoke()
@@ -421,7 +427,7 @@ fun ChatAttachAlert(
val scrimAlpha by animateFloatAsState(
targetValue = if (shouldShow && !isClosing) {
val expandRange = (expandedHeightPx - collapsedStateHeightPx).coerceAtLeast(1f)
val expandProgress = (sheetHeightPx.value - collapsedStateHeightPx) / expandRange
val expandProgress = (interactiveSheetHeightPx - collapsedStateHeightPx) / expandRange
0.25f + 0.15f * expandProgress.coerceIn(0f, 1f)
} else 0f,
animationSpec = tween(
@@ -508,19 +514,20 @@ fun ChatAttachAlert(
shouldShow = true
isClosing = false
showAlbumMenu = false
dragSheetHeightPx = Float.NaN
sheetHeightPx.snapTo(collapsedStateHeightPx)
}
}
// Grow sheet when items selected (collapsed only)
LaunchedEffect(showSheet, state.selectedItemOrder.isNotEmpty(), isExpanded, isClosing, state.editingItem) {
if (showSheet && state.editingItem == null && !isClosing && !isExpanded) {
LaunchedEffect(showSheet, state.selectedItemOrder.isNotEmpty(), isExpanded, isClosing, state.editingItem, dragSheetHeightPx) {
if (showSheet && state.editingItem == null && !isClosing && !isExpanded && dragSheetHeightPx.isNaN()) {
val targetHeight = if (state.selectedItemOrder.isNotEmpty()) {
collapsedStateHeightPx + selectionHeaderExtraPx
} else {
collapsedStateHeightPx
}
if (kotlin.math.abs(sheetHeightPx.value - targetHeight) > 1f) {
if (kotlin.math.abs(interactiveSheetHeightPx - targetHeight) > 1f) {
sheetHeightPx.stop()
sheetHeightPx.animateTo(
targetHeight,
@@ -630,7 +637,12 @@ fun ChatAttachAlert(
fun snapToNearestState(velocity: Float = 0f) {
animationScope.launch {
val currentHeight = sheetHeightPx.value
val currentHeight = interactiveSheetHeightPx
if (!dragSheetHeightPx.isNaN()) {
sheetHeightPx.stop()
sheetHeightPx.snapTo(currentHeight)
dragSheetHeightPx = Float.NaN
}
val velocityThreshold = 180f
val expandSnapThreshold =
collapsedStateHeightPx + (expandedHeightPx - collapsedStateHeightPx) * 0.35f
@@ -697,7 +709,7 @@ fun ChatAttachAlert(
val isPickerFullScreen by remember {
derivedStateOf {
val expandRange = (expandedHeightPx - collapsedStateHeightPx).coerceAtLeast(1f)
val progress = ((sheetHeightPx.value - collapsedStateHeightPx) / expandRange).coerceIn(0f, 1f)
val progress = ((interactiveSheetHeightPx - collapsedStateHeightPx) / expandRange).coerceIn(0f, 1f)
progress > 0.9f
}
}
@@ -918,11 +930,11 @@ fun ChatAttachAlert(
) {
// Sheet height stays constant — keyboard space is handled by
// internal Spacer, not by shrinking the container (Telegram approach).
val visibleSheetHeightPx = (sheetHeightPx.value + navInsetPxForSheet).coerceAtLeast(minHeightPx)
val visibleSheetHeightPx = (interactiveSheetHeightPx + navInsetPxForSheet).coerceAtLeast(minHeightPx)
val currentHeightDp = with(density) { visibleSheetHeightPx.toDp() }
val slideOffset = (visibleSheetHeightPx * animatedOffset).toInt()
val expandProgress =
((sheetHeightPx.value - collapsedStateHeightPx) /
((interactiveSheetHeightPx - collapsedStateHeightPx) /
(expandedHeightPx - collapsedStateHeightPx).coerceAtLeast(1f))
.coerceIn(0f, 1f)
val topCornerRadius by animateDpAsState(
@@ -944,7 +956,6 @@ fun ChatAttachAlert(
val selectedCount = state.selectedCount
var lastDragVelocity by remember { mutableFloatStateOf(0f) }
var dragSnapJob by remember { mutableStateOf<Job?>(null) }
Box(
modifier = Modifier
@@ -965,8 +976,15 @@ fun ChatAttachAlert(
) { /* Prevent click through */ }
.pointerInput(Unit) {
detectVerticalDragGestures(
onDragStart = {
animationScope.launch { sheetHeightPx.stop() }
dragSheetHeightPx = interactiveSheetHeightPx
},
onDragCancel = {
snapToNearestState()
lastDragVelocity = 0f
},
onDragEnd = {
dragSnapJob?.cancel()
snapToNearestState(lastDragVelocity)
lastDragVelocity = 0f
},
@@ -975,12 +993,9 @@ fun ChatAttachAlert(
val adjustedDragAmount =
if (dragAmount < 0f) dragAmount * 1.25f else dragAmount * 0.9f
lastDragVelocity = adjustedDragAmount * 1.8f
val newHeight = (sheetHeightPx.value - adjustedDragAmount)
val newHeight = (interactiveSheetHeightPx - adjustedDragAmount)
.coerceIn(minHeightPx, expandedHeightPx)
dragSnapJob?.cancel()
dragSnapJob = animationScope.launch {
sheetHeightPx.snapTo(newHeight)
}
dragSheetHeightPx = newHeight
}
)
}
@@ -995,7 +1010,7 @@ fun ChatAttachAlert(
val selectionHeaderText = viewModel.selectionHeaderText()
val selectionHeaderHeightPx = if (!isExpanded) {
(sheetHeightPx.value - collapsedStateHeightPx)
(interactiveSheetHeightPx - collapsedStateHeightPx)
.coerceIn(0f, selectionHeaderExtraPx)
} else 0f
val selectionHeaderHeightDp = with(density) { selectionHeaderHeightPx.toDp() }

View File

@@ -759,14 +759,9 @@ fun MessageBubble(
} else if (isStandaloneGroupInvite) {
Modifier.widthIn(min = 180.dp, max = 260.dp)
} else if (hasImageWithCaption || hasOnlyMedia) {
if (hasGroupSenderName) {
Modifier.widthIn(min = photoWidth)
} else {
Modifier.width(
photoWidth
) // 🔥 Фиксированная ширина = размер фото (убирает лишний
// отступ)
}
Modifier.width(
photoWidth
) // Для медиа держим фиксированную ширину как в desktop/telegram
} else {
Modifier.widthIn(min = if (hasGroupSenderName) 120.dp else 60.dp, max = 280.dp)
}
@@ -849,8 +844,19 @@ fun MessageBubble(
!message.isOutgoing &&
senderName.isNotBlank()
) {
val isMediaGroupBubble = hasImageWithCaption || hasOnlyMedia
val senderLabelTopPadding =
if (isMediaGroupBubble) 8.dp else 0.dp
val senderLabelHorizontalPadding =
if (isMediaGroupBubble) 10.dp else 0.dp
Row(
modifier = Modifier.padding(bottom = 4.dp),
modifier =
Modifier.padding(
start = senderLabelHorizontalPadding,
end = senderLabelHorizontalPadding,
top = senderLabelTopPadding,
bottom = 4.dp
),
verticalAlignment = Alignment.CenterVertically
) {
Text(
@@ -2433,6 +2439,7 @@ private fun ForwardedImagePreview(
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit
) {
val context = androidx.compose.ui.platform.LocalContext.current
val configuration = androidx.compose.ui.platform.LocalConfiguration.current
val cacheKey = "img_${attachment.id}"
var imageBitmap by remember(attachment.id) {
@@ -2527,17 +2534,55 @@ private fun ForwardedImagePreview(
}
}
val imgWidth = attachment.width.takeIf { it > 0 } ?: 300
val imgHeight = attachment.height.takeIf { it > 0 } ?: 200
val aspectRatio = (imgWidth.toFloat() / imgHeight.toFloat()).coerceIn(0.5f, 2.5f)
val maxPhotoWidthDp = TelegramBubbleSpec.maxPhotoWidth(configuration.screenWidthDp).dp
val minPhotoWidthDp = TelegramBubbleSpec.minPhotoWidth.dp
val minPhotoHeightDp = TelegramBubbleSpec.minPhotoHeight.dp
val maxPhotoHeightDp = TelegramBubbleSpec.maxPhotoHeight.dp
val (previewWidth, previewHeight) =
remember(
attachment.width,
attachment.height,
configuration.screenWidthDp
) {
val actualWidth = attachment.width.takeIf { it > 0 } ?: 0
val actualHeight = attachment.height.takeIf { it > 0 } ?: 0
if (actualWidth > 0 && actualHeight > 0) {
val ar = actualWidth.toFloat() / actualHeight.toFloat()
var w = if (ar >= 1f) {
maxPhotoWidthDp.value
} else {
maxPhotoWidthDp.value * 0.75f
}
var h = w / ar
if (h > maxPhotoHeightDp.value) {
h = maxPhotoHeightDp.value
w = h * ar
}
if (h < minPhotoHeightDp.value) {
h = minPhotoHeightDp.value
w = h * ar
}
w = w.coerceIn(minPhotoWidthDp.value, maxPhotoWidthDp.value)
h = h.coerceIn(minPhotoHeightDp.value, maxPhotoHeightDp.value)
w.dp to h.dp
} else {
val fallbackWidth = maxPhotoWidthDp
val fallbackHeight = (maxPhotoWidthDp.value * 0.75f)
.coerceIn(minPhotoHeightDp.value, maxPhotoHeightDp.value)
.dp
fallbackWidth to fallbackHeight
}
}
var photoBoxBounds by remember { mutableStateOf<ImageSourceBounds?>(null) }
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(aspectRatio)
.heightIn(max = 200.dp)
.width(previewWidth)
.height(previewHeight)
.clip(RoundedCornerShape(6.dp))
.background(Color.Gray.copy(alpha = 0.2f))
.onGloballyPositioned { coords ->

View File

@@ -73,7 +73,6 @@ import com.rosetta.messenger.ui.utils.NavigationModeUtils
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -323,6 +322,12 @@ fun MediaPickerBottomSheet(
// 🎬 Animatable для плавной высоты - КЛЮЧЕВОЙ ЭЛЕМЕНТ
val sheetHeightPx = remember { Animatable(collapsedHeightPx) }
var dragSheetHeightPx by remember { mutableFloatStateOf(Float.NaN) }
val interactiveSheetHeightPx by remember {
derivedStateOf {
if (dragSheetHeightPx.isNaN()) sheetHeightPx.value else dragSheetHeightPx
}
}
// Текущее состояние (для логики)
var isExpanded by remember { mutableStateOf(false) }
@@ -358,6 +363,7 @@ fun MediaPickerBottomSheet(
closeAction = null
// Сбрасываем высоту
animationScope.launch {
dragSheetHeightPx = Float.NaN
sheetHeightPx.snapTo(collapsedHeightPx)
}
action?.invoke()
@@ -373,7 +379,7 @@ fun MediaPickerBottomSheet(
targetValue = if (shouldShow && !isClosing) {
// Базовое затемнение + немного больше когда развёрнуто
val expandRange = (expandedHeightPx - collapsedStateHeightPx).coerceAtLeast(1f)
val expandProgress = (sheetHeightPx.value - collapsedStateHeightPx) / expandRange
val expandProgress = (interactiveSheetHeightPx - collapsedStateHeightPx) / expandRange
0.25f + 0.15f * expandProgress.coerceIn(0f, 1f)
} else {
0f
@@ -398,20 +404,21 @@ fun MediaPickerBottomSheet(
shouldShow = true
isClosing = false
showAlbumMenu = false
dragSheetHeightPx = Float.NaN
sheetHeightPx.snapTo(collapsedStateHeightPx)
}
}
// Плавно синхронизируем высоту sheet с UI выбора (caption + send) в collapsed-состоянии.
// When items are selected, the sheet grows UPWARD by 26dp to reveal the "N photos selected" header.
LaunchedEffect(showSheet, selectedItemOrder.isNotEmpty(), isExpanded, isClosing, editingItem) {
if (showSheet && editingItem == null && !isClosing && !isExpanded) {
LaunchedEffect(showSheet, selectedItemOrder.isNotEmpty(), isExpanded, isClosing, editingItem, dragSheetHeightPx) {
if (showSheet && editingItem == null && !isClosing && !isExpanded && dragSheetHeightPx.isNaN()) {
val targetHeight = if (selectedItemOrder.isNotEmpty()) {
collapsedStateHeightPx + selectionHeaderExtraPx
} else {
collapsedStateHeightPx
}
if (kotlin.math.abs(sheetHeightPx.value - targetHeight) > 1f) {
if (kotlin.math.abs(interactiveSheetHeightPx - targetHeight) > 1f) {
sheetHeightPx.stop()
sheetHeightPx.animateTo(
targetHeight,
@@ -480,7 +487,12 @@ fun MediaPickerBottomSheet(
// Используем velocity для определения направления
fun snapToNearestState(velocity: Float = 0f) {
animationScope.launch {
val currentHeight = sheetHeightPx.value
val currentHeight = interactiveSheetHeightPx
if (!dragSheetHeightPx.isNaN()) {
sheetHeightPx.stop()
sheetHeightPx.snapTo(currentHeight)
dragSheetHeightPx = Float.NaN
}
// Пороги основаны на velocity (скорости свайпа) - не на позиции!
// velocity < 0 = свайп вверх, velocity > 0 = свайп вниз
@@ -560,7 +572,7 @@ fun MediaPickerBottomSheet(
val isPickerFullScreen by remember {
derivedStateOf {
val expandRange = (expandedHeightPx - collapsedStateHeightPx).coerceAtLeast(1f)
val progress = ((sheetHeightPx.value - collapsedStateHeightPx) / expandRange).coerceIn(0f, 1f)
val progress = ((interactiveSheetHeightPx - collapsedStateHeightPx) / expandRange).coerceIn(0f, 1f)
progress > 0.9f
}
}
@@ -699,12 +711,12 @@ fun MediaPickerBottomSheet(
// Subtract keyboard from sheet height so it fits in the resized viewport.
// The grid (weight=1f) shrinks; caption bar stays at the bottom edge.
val visibleSheetHeightPx =
(sheetHeightPx.value - appliedKeyboardInsetPx + navInsetPxForSheet)
(interactiveSheetHeightPx - appliedKeyboardInsetPx + navInsetPxForSheet)
.coerceAtLeast(minHeightPx)
val currentHeightDp = with(density) { visibleSheetHeightPx.toDp() }
val slideOffset = (visibleSheetHeightPx * animatedOffset).toInt()
val expandProgress =
((sheetHeightPx.value - collapsedStateHeightPx) /
((interactiveSheetHeightPx - collapsedStateHeightPx) /
(expandedHeightPx - collapsedStateHeightPx).coerceAtLeast(1f))
.coerceIn(0f, 1f)
val topCornerRadius by animateDpAsState(
@@ -720,7 +732,6 @@ fun MediaPickerBottomSheet(
// Отслеживаем velocity для плавного snap
var lastDragVelocity by remember { mutableFloatStateOf(0f) }
var dragSnapJob by remember { mutableStateOf<Job?>(null) }
Box(
modifier = Modifier
@@ -742,9 +753,16 @@ fun MediaPickerBottomSheet(
) { /* Prevent click through */ }
.pointerInput(Unit) {
detectVerticalDragGestures(
onDragStart = {
animationScope.launch { sheetHeightPx.stop() }
dragSheetHeightPx = interactiveSheetHeightPx
},
onDragCancel = {
snapToNearestState()
lastDragVelocity = 0f
},
onDragEnd = {
// Snap с учётом velocity
dragSnapJob?.cancel()
snapToNearestState(lastDragVelocity)
lastDragVelocity = 0f
},
@@ -756,12 +774,9 @@ fun MediaPickerBottomSheet(
lastDragVelocity = adjustedDragAmount * 1.8f
// 🔥 Меняем высоту в реальном времени
val newHeight = (sheetHeightPx.value - adjustedDragAmount)
val newHeight = (interactiveSheetHeightPx - adjustedDragAmount)
.coerceIn(minHeightPx, expandedHeightPx)
dragSnapJob?.cancel()
dragSnapJob = animationScope.launch {
sheetHeightPx.snapTo(newHeight)
}
dragSheetHeightPx = newHeight
}
)
}
@@ -809,7 +824,7 @@ fun MediaPickerBottomSheet(
// Header height is DERIVED from how much the sheet has grown
// beyond collapsedStateHeightPx — perfectly in sync, no jerk.
val selectionHeaderHeightPx = if (!isExpanded) {
(sheetHeightPx.value - collapsedStateHeightPx)
(interactiveSheetHeightPx - collapsedStateHeightPx)
.coerceIn(0f, selectionHeaderExtraPx)
} else 0f
val selectionHeaderHeightDp = with(density) { selectionHeaderHeightPx.toDp() }

View File

@@ -88,9 +88,17 @@ fun ProfilePhotoPicker(
var shouldShow by remember { mutableStateOf(false) }
var isClosing by remember { mutableStateOf(false) }
// Animatable для высоты sheet с drag support
val sheetHeightPx = remember { Animatable(0f) }
val targetHeightPx = screenHeightPx * 0.92f // 92% экрана
var dragSheetOffsetPx by remember { mutableFloatStateOf(0f) }
var isDraggingSheet by remember { mutableStateOf(false) }
val animatedDragSheetOffsetPx by animateFloatAsState(
targetValue = dragSheetOffsetPx,
animationSpec = if (isDraggingSheet) tween(durationMillis = 0)
else spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow
),
label = "profile_picker_drag_offset"
)
// Permission launcher
val permissionLauncher = rememberLauncherForActivityResult(
@@ -140,9 +148,8 @@ fun ProfilePhotoPicker(
if (isClosing) {
isClosing = false
shouldShow = false
scope.launch {
sheetHeightPx.snapTo(0f)
}
dragSheetOffsetPx = 0f
isDraggingSheet = false
onDismiss()
}
},
@@ -164,7 +171,8 @@ fun ProfilePhotoPicker(
if (isVisible) {
shouldShow = true
isClosing = false
sheetHeightPx.snapTo(targetHeightPx)
dragSheetOffsetPx = 0f
isDraggingSheet = false
}
}
@@ -235,24 +243,32 @@ fun ProfilePhotoPicker(
.fillMaxWidth()
.fillMaxHeight(0.92f)
.offset {
IntOffset(0, (screenHeightPx * animatedOffset).roundToInt())
IntOffset(
0,
(screenHeightPx * animatedOffset + animatedDragSheetOffsetPx).roundToInt()
)
}
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
.pointerInput(Unit) {
// Drag to dismiss
var totalDrag = 0f
detectVerticalDragGestures(
onDragStart = { totalDrag = 0f },
onDragStart = { isDraggingSheet = true },
onDragEnd = {
if (totalDrag > 150f) {
isDraggingSheet = false
if (dragSheetOffsetPx > screenHeightPx * 0.16f) {
animatedClose()
} else {
dragSheetOffsetPx = 0f
}
},
onDragCancel = { totalDrag = 0f },
onVerticalDrag = { _, dragAmount ->
if (dragAmount > 0) { // Only downward
totalDrag += dragAmount
}
onDragCancel = {
isDraggingSheet = false
dragSheetOffsetPx = 0f
},
onVerticalDrag = { change, dragAmount ->
change.consume()
dragSheetOffsetPx =
(dragSheetOffsetPx + dragAmount).coerceIn(0f, screenHeightPx)
}
)
},