Сделан интерактивный drag медиа-пикеров и обновлены release notes
Some checks failed
Android Kernel Build / build (push) Has been cancelled

This commit is contained in:
2026-03-18 23:35:38 +05:00
parent 5e66437239
commit b918b45603
5 changed files with 108 additions and 59 deletions

View File

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

View File

@@ -1717,14 +1717,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

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

@@ -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)
}
)
},