Сделан интерактивный drag медиа-пикеров и обновлены release notes
Some checks failed
Android Kernel Build / build (push) Has been cancelled
Some checks failed
Android Kernel Build / build (push) Has been cancelled
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user