Compare commits
24 Commits
fbae1283ca
...
6e14213b5c
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e14213b5c | |||
| af4a3a5f27 | |||
| 9a411ac473 | |||
| 75d0f4726b | |||
| 581a44b270 | |||
| cd325bea87 | |||
| b918b45603 | |||
| 5e66437239 | |||
| 670093c8fe | |||
| c2198b624d | |||
| 807309a812 | |||
| 479fdd0074 | |||
| b1d4458484 | |||
| b5398e2f1d | |||
| bae665f89d | |||
| a5ec0595ad | |||
| d78fb184c6 | |||
| ddd98a8065 | |||
| d9c54b2d05 | |||
| 494b459e39 | |||
| 8c2e30b4d8 | |||
| 0aa34e75c9 | |||
| 43bcfdff1b | |||
| 982dfc5dff |
@@ -1,5 +1,16 @@
|
|||||||
# Release Notes
|
# Release Notes
|
||||||
|
|
||||||
|
## 1.2.3
|
||||||
|
|
||||||
|
### Групповые чаты и медиа
|
||||||
|
- Исправлено отображение групповых баблов: логика стеков и аватаров приведена ближе к desktop-версии.
|
||||||
|
- Исправлено позиционирование аватарки в группе: аватар и имя теперь отображаются на одном сообщении (без «разъезда»).
|
||||||
|
- Исправлена обрезка имени отправителя в медиа-баблах группового чата.
|
||||||
|
- Исправлено растяжение и кривые пропорции фото в forwarded/media-пузырях.
|
||||||
|
|
||||||
|
### Sidebar
|
||||||
|
- Убрана лишняя рамка (border) вокруг аватарки в сайдбаре.
|
||||||
|
|
||||||
## 1.2.1
|
## 1.2.1
|
||||||
|
|
||||||
### Синхронизация Android ↔ iOS
|
### Синхронизация Android ↔ iOS
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
|||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// Rosetta versioning — bump here on each release
|
// Rosetta versioning — bump here on each release
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
val rosettaVersionName = "1.2.1"
|
val rosettaVersionName = "1.2.3"
|
||||||
val rosettaVersionCode = 23 // Increment on each release
|
val rosettaVersionCode = 25 // Increment on each release
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.rosetta.messenger"
|
namespace = "com.rosetta.messenger"
|
||||||
|
|||||||
@@ -17,14 +17,16 @@ object ReleaseNotes {
|
|||||||
val RELEASE_NOTICE = """
|
val RELEASE_NOTICE = """
|
||||||
Update v$VERSION_PLACEHOLDER
|
Update v$VERSION_PLACEHOLDER
|
||||||
|
|
||||||
Синхронизация Android ↔ iOS
|
Что обновлено после версии 1.2.2
|
||||||
- Исправлена проблема: сообщения зависали на «часиках» при одновременном использовании Android и iOS
|
|
||||||
- Добавлен механизм автоматического повтора отправки: 3 попытки с интервалом 4 сек, таймаут 80 сек
|
Группы и медиа
|
||||||
- Исправлена нормализация sync-курсора для корректной синхронизации между устройствами
|
- Исправлено отображение групповых баблов и стеков сообщений
|
||||||
|
- Исправлено позиционирование аватарки: имя и аватар в группе теперь не разъезжаются
|
||||||
|
- Исправлена обрезка имени отправителя в медиа-баблах
|
||||||
|
- Исправлено растяжение фото в forwarded/media-пузырях
|
||||||
|
|
||||||
Интерфейс
|
Интерфейс
|
||||||
- Дата «today/yesterday» и пустой стейт чата теперь белые при тёмных обоях или тёмной теме
|
- Убрана лишняя рамка вокруг аватарки в боковом меню
|
||||||
- Исправлена обрезка имени отправителя в групповых чатах
|
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
fun getNotice(version: String): String =
|
fun getNotice(version: String): String =
|
||||||
|
|||||||
@@ -126,6 +126,25 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
private val groupMembersCountCache = java.util.concurrent.ConcurrentHashMap<String, Int>()
|
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(
|
private data class IncomingRunAvatarAccumulator(
|
||||||
val senderPublicKey: String,
|
val senderPublicKey: String,
|
||||||
@@ -777,16 +796,26 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
val isMessageBoundary: (ChatMessage, ChatMessage?) -> Boolean =
|
val isMessageBoundary: (ChatMessage, ChatMessage?) -> Boolean =
|
||||||
remember(isGroupChat, currentUserPublicKey, user.publicKey) {
|
remember(isGroupChat, currentUserPublicKey, user.publicKey) {
|
||||||
|
val maxStackTimeDiffMs =
|
||||||
|
if (isGroupChat) {
|
||||||
|
GROUP_MESSAGE_STACK_TIME_DIFF_MS
|
||||||
|
} else {
|
||||||
|
DIRECT_MESSAGE_STACK_TIME_DIFF_MS
|
||||||
|
}
|
||||||
{ currentMessage, adjacentMessage ->
|
{ currentMessage, adjacentMessage ->
|
||||||
if (adjacentMessage == null) {
|
if (adjacentMessage == null) {
|
||||||
true
|
true
|
||||||
} else if (adjacentMessage.isOutgoing != currentMessage.isOutgoing) {
|
} else if (adjacentMessage.isOutgoing != currentMessage.isOutgoing) {
|
||||||
true
|
true
|
||||||
} else if (
|
} else if (
|
||||||
kotlin.math.abs(
|
!isSameLocalDay(
|
||||||
currentMessage.timestamp.time -
|
currentMessage.timestamp.time,
|
||||||
adjacentMessage.timestamp.time
|
adjacentMessage.timestamp.time
|
||||||
) > 60_000L
|
) ||
|
||||||
|
kotlin.math.abs(
|
||||||
|
currentMessage.timestamp.time -
|
||||||
|
adjacentMessage.timestamp.time
|
||||||
|
) > maxStackTimeDiffMs
|
||||||
) {
|
) {
|
||||||
true
|
true
|
||||||
} else if (
|
} else if (
|
||||||
@@ -1739,14 +1768,18 @@ fun ChatDetailScreen(
|
|||||||
} // Закрытие Crossfade
|
} // Закрытие Crossfade
|
||||||
|
|
||||||
// Bottom line для unified header
|
// 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(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.align(Alignment.BottomCenter)
|
Modifier.align(Alignment.BottomCenter)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(0.5.dp)
|
.height(0.5.dp)
|
||||||
.background(
|
.background(headerDividerColor)
|
||||||
Color.White.copy(alpha = 0.15f)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
} // Закрытие Box unified header
|
} // Закрытие Box unified header
|
||||||
|
|
||||||
@@ -2527,41 +2560,12 @@ fun ChatDetailScreen(
|
|||||||
isMessageBoundary(message, prevMessage)
|
isMessageBoundary(message, prevMessage)
|
||||||
val isGroupStart =
|
val isGroupStart =
|
||||||
isMessageBoundary(message, nextMessage)
|
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 =
|
val showIncomingGroupAvatar =
|
||||||
isGroupChat &&
|
isGroupChat &&
|
||||||
!message.isOutgoing &&
|
!message.isOutgoing &&
|
||||||
senderPublicKeyForMessage
|
senderPublicKeyForMessage
|
||||||
.isNotBlank() &&
|
.isNotBlank() &&
|
||||||
((index ==
|
isGroupStart
|
||||||
runHeadIndex &&
|
|
||||||
isHeadPhase &&
|
|
||||||
showTail) ||
|
|
||||||
(index ==
|
|
||||||
runTailIndex &&
|
|
||||||
isTailPhase &&
|
|
||||||
isGroupStart))
|
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
if (showDate
|
if (showDate
|
||||||
|
|||||||
@@ -902,37 +902,60 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
// 🔥 Берём ТЕКУЩЕЕ состояние UI (может уже содержать новые сообщения)
|
// 🔥 Берём ТЕКУЩЕЕ состояние UI (может уже содержать новые сообщения)
|
||||||
val currentMessages = _messages.value
|
val currentMessages = _messages.value
|
||||||
val existingIds = currentMessages.map { it.id }.toSet()
|
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)
|
// 🔥 Находим только НОВЫЕ сообщения (которых нет в текущем UI)
|
||||||
val newEntities = entities.filter { it.messageId !in existingIds }
|
val newEntities = entities.filter { it.messageId !in existingIds }
|
||||||
|
|
||||||
if (newEntities.isNotEmpty()) {
|
val newMessages =
|
||||||
val semaphore = Semaphore(DECRYPT_PARALLELISM)
|
if (newEntities.isNotEmpty()) {
|
||||||
val newMessages = coroutineScope {
|
val semaphore = Semaphore(DECRYPT_PARALLELISM)
|
||||||
newEntities
|
coroutineScope {
|
||||||
.map { entity ->
|
newEntities
|
||||||
async { semaphore.withPermit { entityToChatMessage(entity) } }
|
.map { entity ->
|
||||||
}
|
async { semaphore.withPermit { entityToChatMessage(entity) } }
|
||||||
.awaitAll()
|
}
|
||||||
}
|
.awaitAll()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
// 🔥 ДОБАВЛЯЕМ новые к текущим, а не заменяем!
|
if (hasExistingUpdates || newMessages.isNotEmpty()) {
|
||||||
// Сортируем по timestamp чтобы новые были в конце
|
// 🔥 ДОБАВЛЯЕМ новые + применяем статусные апдейты к существующим
|
||||||
val updatedMessages =
|
val updatedMessages =
|
||||||
sortMessagesAscending((currentMessages + newMessages).distinctBy { it.id })
|
sortMessagesAscending((reconciledMessages + newMessages).distinctBy { it.id })
|
||||||
|
|
||||||
// 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш сохраняя ВСЕ сообщения, не только отображаемые!
|
// 🔥 Обновляем кэш: сохраняем старые страницы + применяем новые статусы/время
|
||||||
// Объединяем существующий кэш с новыми сообщениями
|
|
||||||
val existingCache = dialogMessagesCache[cacheKey(account, dialogKey)] ?: emptyList()
|
val existingCache = dialogMessagesCache[cacheKey(account, dialogKey)] ?: emptyList()
|
||||||
val allCachedIds = existingCache.map { it.id }.toSet()
|
val mergedCache = sortMessagesAscending((existingCache + updatedMessages).distinctBy { it.id })
|
||||||
val trulyNewMessages = newMessages.filter { it.id !in allCachedIds }
|
updateCacheWithLimit(account, dialogKey, mergedCache)
|
||||||
if (trulyNewMessages.isNotEmpty()) {
|
|
||||||
updateCacheWithLimit(
|
|
||||||
account,
|
|
||||||
dialogKey,
|
|
||||||
sortMessagesAscending(existingCache + trulyNewMessages)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main.immediate) { _messages.value = updatedMessages }
|
withContext(Dispatchers.Main.immediate) { _messages.value = updatedMessages }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -834,10 +834,6 @@ fun ChatsListScreen(
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier.size(72.dp)
|
Modifier.size(72.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(
|
|
||||||
Color.White
|
|
||||||
.copy(alpha = 0.2f)
|
|
||||||
)
|
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -858,8 +854,7 @@ fun ChatsListScreen(
|
|||||||
accountPublicKey
|
accountPublicKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
),
|
||||||
.padding(3.dp),
|
|
||||||
contentAlignment =
|
contentAlignment =
|
||||||
Alignment.Center
|
Alignment.Center
|
||||||
) {
|
) {
|
||||||
@@ -868,7 +863,7 @@ fun ChatsListScreen(
|
|||||||
accountPublicKey,
|
accountPublicKey,
|
||||||
avatarRepository =
|
avatarRepository =
|
||||||
avatarRepository,
|
avatarRepository,
|
||||||
size = 66.dp,
|
size = 72.dp,
|
||||||
isDarkTheme =
|
isDarkTheme =
|
||||||
isDarkTheme,
|
isDarkTheme,
|
||||||
displayName =
|
displayName =
|
||||||
@@ -928,27 +923,24 @@ fun ChatsListScreen(
|
|||||||
// Display name
|
// Display name
|
||||||
if (accountName.isNotEmpty()) {
|
if (accountName.isNotEmpty()) {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.Start
|
horizontalArrangement = Arrangement.Start,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = accountName,
|
text = accountName,
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = Color.White,
|
color = Color.White
|
||||||
modifier = Modifier.alignByBaseline()
|
|
||||||
)
|
)
|
||||||
if (accountVerified > 0 || isRosettaOfficial || isFreddyOfficial) {
|
if (accountVerified > 0 || isRosettaOfficial || isFreddyOfficial) {
|
||||||
Box(
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
modifier =
|
VerifiedBadge(
|
||||||
Modifier.padding(start = 2.dp)
|
verified = if (accountVerified > 0) accountVerified else 1,
|
||||||
.alignBy { (it.measuredHeight * 0.78f).toInt() }
|
size = 15,
|
||||||
) {
|
modifier = Modifier.offset(y = 1.dp),
|
||||||
VerifiedBadge(
|
isDarkTheme = isDarkTheme,
|
||||||
verified = if (accountVerified > 0) accountVerified else 1,
|
badgeTint = if (isDarkTheme) PrimaryBlue else Color.White
|
||||||
size = 15,
|
)
|
||||||
badgeTint = if (isDarkTheme) PrimaryBlue else Color.White
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
|
|||||||
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
|
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
import com.rosetta.messenger.ui.utils.NavigationModeUtils
|
import com.rosetta.messenger.ui.utils.NavigationModeUtils
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -352,6 +351,12 @@ fun ChatAttachAlert(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val sheetHeightPx = remember { Animatable(collapsedHeightPx) }
|
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()
|
val animationScope = rememberCoroutineScope()
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
@@ -409,6 +414,7 @@ fun ChatAttachAlert(
|
|||||||
val action = closeAction
|
val action = closeAction
|
||||||
closeAction = null
|
closeAction = null
|
||||||
animationScope.launch {
|
animationScope.launch {
|
||||||
|
dragSheetHeightPx = Float.NaN
|
||||||
sheetHeightPx.snapTo(collapsedHeightPx)
|
sheetHeightPx.snapTo(collapsedHeightPx)
|
||||||
}
|
}
|
||||||
action?.invoke()
|
action?.invoke()
|
||||||
@@ -421,7 +427,7 @@ fun ChatAttachAlert(
|
|||||||
val scrimAlpha by animateFloatAsState(
|
val scrimAlpha by animateFloatAsState(
|
||||||
targetValue = if (shouldShow && !isClosing) {
|
targetValue = if (shouldShow && !isClosing) {
|
||||||
val expandRange = (expandedHeightPx - collapsedStateHeightPx).coerceAtLeast(1f)
|
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)
|
0.25f + 0.15f * expandProgress.coerceIn(0f, 1f)
|
||||||
} else 0f,
|
} else 0f,
|
||||||
animationSpec = tween(
|
animationSpec = tween(
|
||||||
@@ -508,19 +514,20 @@ fun ChatAttachAlert(
|
|||||||
shouldShow = true
|
shouldShow = true
|
||||||
isClosing = false
|
isClosing = false
|
||||||
showAlbumMenu = false
|
showAlbumMenu = false
|
||||||
|
dragSheetHeightPx = Float.NaN
|
||||||
sheetHeightPx.snapTo(collapsedStateHeightPx)
|
sheetHeightPx.snapTo(collapsedStateHeightPx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grow sheet when items selected (collapsed only)
|
// Grow sheet when items selected (collapsed only)
|
||||||
LaunchedEffect(showSheet, state.selectedItemOrder.isNotEmpty(), isExpanded, isClosing, state.editingItem) {
|
LaunchedEffect(showSheet, state.selectedItemOrder.isNotEmpty(), isExpanded, isClosing, state.editingItem, dragSheetHeightPx) {
|
||||||
if (showSheet && state.editingItem == null && !isClosing && !isExpanded) {
|
if (showSheet && state.editingItem == null && !isClosing && !isExpanded && dragSheetHeightPx.isNaN()) {
|
||||||
val targetHeight = if (state.selectedItemOrder.isNotEmpty()) {
|
val targetHeight = if (state.selectedItemOrder.isNotEmpty()) {
|
||||||
collapsedStateHeightPx + selectionHeaderExtraPx
|
collapsedStateHeightPx + selectionHeaderExtraPx
|
||||||
} else {
|
} else {
|
||||||
collapsedStateHeightPx
|
collapsedStateHeightPx
|
||||||
}
|
}
|
||||||
if (kotlin.math.abs(sheetHeightPx.value - targetHeight) > 1f) {
|
if (kotlin.math.abs(interactiveSheetHeightPx - targetHeight) > 1f) {
|
||||||
sheetHeightPx.stop()
|
sheetHeightPx.stop()
|
||||||
sheetHeightPx.animateTo(
|
sheetHeightPx.animateTo(
|
||||||
targetHeight,
|
targetHeight,
|
||||||
@@ -630,7 +637,12 @@ fun ChatAttachAlert(
|
|||||||
|
|
||||||
fun snapToNearestState(velocity: Float = 0f) {
|
fun snapToNearestState(velocity: Float = 0f) {
|
||||||
animationScope.launch {
|
animationScope.launch {
|
||||||
val currentHeight = sheetHeightPx.value
|
val currentHeight = interactiveSheetHeightPx
|
||||||
|
if (!dragSheetHeightPx.isNaN()) {
|
||||||
|
sheetHeightPx.stop()
|
||||||
|
sheetHeightPx.snapTo(currentHeight)
|
||||||
|
dragSheetHeightPx = Float.NaN
|
||||||
|
}
|
||||||
val velocityThreshold = 180f
|
val velocityThreshold = 180f
|
||||||
val expandSnapThreshold =
|
val expandSnapThreshold =
|
||||||
collapsedStateHeightPx + (expandedHeightPx - collapsedStateHeightPx) * 0.35f
|
collapsedStateHeightPx + (expandedHeightPx - collapsedStateHeightPx) * 0.35f
|
||||||
@@ -697,7 +709,7 @@ fun ChatAttachAlert(
|
|||||||
val isPickerFullScreen by remember {
|
val isPickerFullScreen by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val expandRange = (expandedHeightPx - collapsedStateHeightPx).coerceAtLeast(1f)
|
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
|
progress > 0.9f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -918,11 +930,11 @@ fun ChatAttachAlert(
|
|||||||
) {
|
) {
|
||||||
// Sheet height stays constant — keyboard space is handled by
|
// Sheet height stays constant — keyboard space is handled by
|
||||||
// internal Spacer, not by shrinking the container (Telegram approach).
|
// 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 currentHeightDp = with(density) { visibleSheetHeightPx.toDp() }
|
||||||
val slideOffset = (visibleSheetHeightPx * animatedOffset).toInt()
|
val slideOffset = (visibleSheetHeightPx * animatedOffset).toInt()
|
||||||
val expandProgress =
|
val expandProgress =
|
||||||
((sheetHeightPx.value - collapsedStateHeightPx) /
|
((interactiveSheetHeightPx - collapsedStateHeightPx) /
|
||||||
(expandedHeightPx - collapsedStateHeightPx).coerceAtLeast(1f))
|
(expandedHeightPx - collapsedStateHeightPx).coerceAtLeast(1f))
|
||||||
.coerceIn(0f, 1f)
|
.coerceIn(0f, 1f)
|
||||||
val topCornerRadius by animateDpAsState(
|
val topCornerRadius by animateDpAsState(
|
||||||
@@ -944,7 +956,6 @@ fun ChatAttachAlert(
|
|||||||
val selectedCount = state.selectedCount
|
val selectedCount = state.selectedCount
|
||||||
|
|
||||||
var lastDragVelocity by remember { mutableFloatStateOf(0f) }
|
var lastDragVelocity by remember { mutableFloatStateOf(0f) }
|
||||||
var dragSnapJob by remember { mutableStateOf<Job?>(null) }
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -965,8 +976,15 @@ fun ChatAttachAlert(
|
|||||||
) { /* Prevent click through */ }
|
) { /* Prevent click through */ }
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
detectVerticalDragGestures(
|
detectVerticalDragGestures(
|
||||||
|
onDragStart = {
|
||||||
|
animationScope.launch { sheetHeightPx.stop() }
|
||||||
|
dragSheetHeightPx = interactiveSheetHeightPx
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
snapToNearestState()
|
||||||
|
lastDragVelocity = 0f
|
||||||
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
dragSnapJob?.cancel()
|
|
||||||
snapToNearestState(lastDragVelocity)
|
snapToNearestState(lastDragVelocity)
|
||||||
lastDragVelocity = 0f
|
lastDragVelocity = 0f
|
||||||
},
|
},
|
||||||
@@ -975,12 +993,9 @@ fun ChatAttachAlert(
|
|||||||
val adjustedDragAmount =
|
val adjustedDragAmount =
|
||||||
if (dragAmount < 0f) dragAmount * 1.25f else dragAmount * 0.9f
|
if (dragAmount < 0f) dragAmount * 1.25f else dragAmount * 0.9f
|
||||||
lastDragVelocity = adjustedDragAmount * 1.8f
|
lastDragVelocity = adjustedDragAmount * 1.8f
|
||||||
val newHeight = (sheetHeightPx.value - adjustedDragAmount)
|
val newHeight = (interactiveSheetHeightPx - adjustedDragAmount)
|
||||||
.coerceIn(minHeightPx, expandedHeightPx)
|
.coerceIn(minHeightPx, expandedHeightPx)
|
||||||
dragSnapJob?.cancel()
|
dragSheetHeightPx = newHeight
|
||||||
dragSnapJob = animationScope.launch {
|
|
||||||
sheetHeightPx.snapTo(newHeight)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -995,7 +1010,7 @@ fun ChatAttachAlert(
|
|||||||
val selectionHeaderText = viewModel.selectionHeaderText()
|
val selectionHeaderText = viewModel.selectionHeaderText()
|
||||||
|
|
||||||
val selectionHeaderHeightPx = if (!isExpanded) {
|
val selectionHeaderHeightPx = if (!isExpanded) {
|
||||||
(sheetHeightPx.value - collapsedStateHeightPx)
|
(interactiveSheetHeightPx - collapsedStateHeightPx)
|
||||||
.coerceIn(0f, selectionHeaderExtraPx)
|
.coerceIn(0f, selectionHeaderExtraPx)
|
||||||
} else 0f
|
} else 0f
|
||||||
val selectionHeaderHeightDp = with(density) { selectionHeaderHeightPx.toDp() }
|
val selectionHeaderHeightDp = with(density) { selectionHeaderHeightPx.toDp() }
|
||||||
|
|||||||
@@ -759,14 +759,9 @@ fun MessageBubble(
|
|||||||
} else if (isStandaloneGroupInvite) {
|
} else if (isStandaloneGroupInvite) {
|
||||||
Modifier.widthIn(min = 180.dp, max = 260.dp)
|
Modifier.widthIn(min = 180.dp, max = 260.dp)
|
||||||
} else if (hasImageWithCaption || hasOnlyMedia) {
|
} else if (hasImageWithCaption || hasOnlyMedia) {
|
||||||
if (hasGroupSenderName) {
|
Modifier.width(
|
||||||
Modifier.widthIn(min = photoWidth)
|
photoWidth
|
||||||
} else {
|
) // Для медиа держим фиксированную ширину как в desktop/telegram
|
||||||
Modifier.width(
|
|
||||||
photoWidth
|
|
||||||
) // 🔥 Фиксированная ширина = размер фото (убирает лишний
|
|
||||||
// отступ)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Modifier.widthIn(min = if (hasGroupSenderName) 120.dp else 60.dp, max = 280.dp)
|
Modifier.widthIn(min = if (hasGroupSenderName) 120.dp else 60.dp, max = 280.dp)
|
||||||
}
|
}
|
||||||
@@ -849,8 +844,19 @@ fun MessageBubble(
|
|||||||
!message.isOutgoing &&
|
!message.isOutgoing &&
|
||||||
senderName.isNotBlank()
|
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(
|
Row(
|
||||||
modifier = Modifier.padding(bottom = 4.dp),
|
modifier =
|
||||||
|
Modifier.padding(
|
||||||
|
start = senderLabelHorizontalPadding,
|
||||||
|
end = senderLabelHorizontalPadding,
|
||||||
|
top = senderLabelTopPadding,
|
||||||
|
bottom = 4.dp
|
||||||
|
),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -2433,6 +2439,7 @@ private fun ForwardedImagePreview(
|
|||||||
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit
|
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
val configuration = androidx.compose.ui.platform.LocalConfiguration.current
|
||||||
val cacheKey = "img_${attachment.id}"
|
val cacheKey = "img_${attachment.id}"
|
||||||
|
|
||||||
var imageBitmap by remember(attachment.id) {
|
var imageBitmap by remember(attachment.id) {
|
||||||
@@ -2527,17 +2534,55 @@ private fun ForwardedImagePreview(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val imgWidth = attachment.width.takeIf { it > 0 } ?: 300
|
val maxPhotoWidthDp = TelegramBubbleSpec.maxPhotoWidth(configuration.screenWidthDp).dp
|
||||||
val imgHeight = attachment.height.takeIf { it > 0 } ?: 200
|
val minPhotoWidthDp = TelegramBubbleSpec.minPhotoWidth.dp
|
||||||
val aspectRatio = (imgWidth.toFloat() / imgHeight.toFloat()).coerceIn(0.5f, 2.5f)
|
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) }
|
var photoBoxBounds by remember { mutableStateOf<ImageSourceBounds?>(null) }
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.width(previewWidth)
|
||||||
.aspectRatio(aspectRatio)
|
.height(previewHeight)
|
||||||
.heightIn(max = 200.dp)
|
|
||||||
.clip(RoundedCornerShape(6.dp))
|
.clip(RoundedCornerShape(6.dp))
|
||||||
.background(Color.Gray.copy(alpha = 0.2f))
|
.background(Color.Gray.copy(alpha = 0.2f))
|
||||||
.onGloballyPositioned { coords ->
|
.onGloballyPositioned { coords ->
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ import com.rosetta.messenger.ui.utils.NavigationModeUtils
|
|||||||
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
|
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
|
||||||
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
|
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -323,6 +322,12 @@ fun MediaPickerBottomSheet(
|
|||||||
|
|
||||||
// 🎬 Animatable для плавной высоты - КЛЮЧЕВОЙ ЭЛЕМЕНТ
|
// 🎬 Animatable для плавной высоты - КЛЮЧЕВОЙ ЭЛЕМЕНТ
|
||||||
val sheetHeightPx = remember { Animatable(collapsedHeightPx) }
|
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) }
|
var isExpanded by remember { mutableStateOf(false) }
|
||||||
@@ -358,6 +363,7 @@ fun MediaPickerBottomSheet(
|
|||||||
closeAction = null
|
closeAction = null
|
||||||
// Сбрасываем высоту
|
// Сбрасываем высоту
|
||||||
animationScope.launch {
|
animationScope.launch {
|
||||||
|
dragSheetHeightPx = Float.NaN
|
||||||
sheetHeightPx.snapTo(collapsedHeightPx)
|
sheetHeightPx.snapTo(collapsedHeightPx)
|
||||||
}
|
}
|
||||||
action?.invoke()
|
action?.invoke()
|
||||||
@@ -373,7 +379,7 @@ fun MediaPickerBottomSheet(
|
|||||||
targetValue = if (shouldShow && !isClosing) {
|
targetValue = if (shouldShow && !isClosing) {
|
||||||
// Базовое затемнение + немного больше когда развёрнуто
|
// Базовое затемнение + немного больше когда развёрнуто
|
||||||
val expandRange = (expandedHeightPx - collapsedStateHeightPx).coerceAtLeast(1f)
|
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)
|
0.25f + 0.15f * expandProgress.coerceIn(0f, 1f)
|
||||||
} else {
|
} else {
|
||||||
0f
|
0f
|
||||||
@@ -398,20 +404,21 @@ fun MediaPickerBottomSheet(
|
|||||||
shouldShow = true
|
shouldShow = true
|
||||||
isClosing = false
|
isClosing = false
|
||||||
showAlbumMenu = false
|
showAlbumMenu = false
|
||||||
|
dragSheetHeightPx = Float.NaN
|
||||||
sheetHeightPx.snapTo(collapsedStateHeightPx)
|
sheetHeightPx.snapTo(collapsedStateHeightPx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Плавно синхронизируем высоту sheet с UI выбора (caption + send) в collapsed-состоянии.
|
// Плавно синхронизируем высоту sheet с UI выбора (caption + send) в collapsed-состоянии.
|
||||||
// When items are selected, the sheet grows UPWARD by 26dp to reveal the "N photos selected" header.
|
// When items are selected, the sheet grows UPWARD by 26dp to reveal the "N photos selected" header.
|
||||||
LaunchedEffect(showSheet, selectedItemOrder.isNotEmpty(), isExpanded, isClosing, editingItem) {
|
LaunchedEffect(showSheet, selectedItemOrder.isNotEmpty(), isExpanded, isClosing, editingItem, dragSheetHeightPx) {
|
||||||
if (showSheet && editingItem == null && !isClosing && !isExpanded) {
|
if (showSheet && editingItem == null && !isClosing && !isExpanded && dragSheetHeightPx.isNaN()) {
|
||||||
val targetHeight = if (selectedItemOrder.isNotEmpty()) {
|
val targetHeight = if (selectedItemOrder.isNotEmpty()) {
|
||||||
collapsedStateHeightPx + selectionHeaderExtraPx
|
collapsedStateHeightPx + selectionHeaderExtraPx
|
||||||
} else {
|
} else {
|
||||||
collapsedStateHeightPx
|
collapsedStateHeightPx
|
||||||
}
|
}
|
||||||
if (kotlin.math.abs(sheetHeightPx.value - targetHeight) > 1f) {
|
if (kotlin.math.abs(interactiveSheetHeightPx - targetHeight) > 1f) {
|
||||||
sheetHeightPx.stop()
|
sheetHeightPx.stop()
|
||||||
sheetHeightPx.animateTo(
|
sheetHeightPx.animateTo(
|
||||||
targetHeight,
|
targetHeight,
|
||||||
@@ -480,7 +487,12 @@ fun MediaPickerBottomSheet(
|
|||||||
// Используем velocity для определения направления
|
// Используем velocity для определения направления
|
||||||
fun snapToNearestState(velocity: Float = 0f) {
|
fun snapToNearestState(velocity: Float = 0f) {
|
||||||
animationScope.launch {
|
animationScope.launch {
|
||||||
val currentHeight = sheetHeightPx.value
|
val currentHeight = interactiveSheetHeightPx
|
||||||
|
if (!dragSheetHeightPx.isNaN()) {
|
||||||
|
sheetHeightPx.stop()
|
||||||
|
sheetHeightPx.snapTo(currentHeight)
|
||||||
|
dragSheetHeightPx = Float.NaN
|
||||||
|
}
|
||||||
|
|
||||||
// Пороги основаны на velocity (скорости свайпа) - не на позиции!
|
// Пороги основаны на velocity (скорости свайпа) - не на позиции!
|
||||||
// velocity < 0 = свайп вверх, velocity > 0 = свайп вниз
|
// velocity < 0 = свайп вверх, velocity > 0 = свайп вниз
|
||||||
@@ -560,7 +572,7 @@ fun MediaPickerBottomSheet(
|
|||||||
val isPickerFullScreen by remember {
|
val isPickerFullScreen by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val expandRange = (expandedHeightPx - collapsedStateHeightPx).coerceAtLeast(1f)
|
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
|
progress > 0.9f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -699,12 +711,12 @@ fun MediaPickerBottomSheet(
|
|||||||
// Subtract keyboard from sheet height so it fits in the resized viewport.
|
// Subtract keyboard from sheet height so it fits in the resized viewport.
|
||||||
// The grid (weight=1f) shrinks; caption bar stays at the bottom edge.
|
// The grid (weight=1f) shrinks; caption bar stays at the bottom edge.
|
||||||
val visibleSheetHeightPx =
|
val visibleSheetHeightPx =
|
||||||
(sheetHeightPx.value - appliedKeyboardInsetPx + navInsetPxForSheet)
|
(interactiveSheetHeightPx - appliedKeyboardInsetPx + navInsetPxForSheet)
|
||||||
.coerceAtLeast(minHeightPx)
|
.coerceAtLeast(minHeightPx)
|
||||||
val currentHeightDp = with(density) { visibleSheetHeightPx.toDp() }
|
val currentHeightDp = with(density) { visibleSheetHeightPx.toDp() }
|
||||||
val slideOffset = (visibleSheetHeightPx * animatedOffset).toInt()
|
val slideOffset = (visibleSheetHeightPx * animatedOffset).toInt()
|
||||||
val expandProgress =
|
val expandProgress =
|
||||||
((sheetHeightPx.value - collapsedStateHeightPx) /
|
((interactiveSheetHeightPx - collapsedStateHeightPx) /
|
||||||
(expandedHeightPx - collapsedStateHeightPx).coerceAtLeast(1f))
|
(expandedHeightPx - collapsedStateHeightPx).coerceAtLeast(1f))
|
||||||
.coerceIn(0f, 1f)
|
.coerceIn(0f, 1f)
|
||||||
val topCornerRadius by animateDpAsState(
|
val topCornerRadius by animateDpAsState(
|
||||||
@@ -720,7 +732,6 @@ fun MediaPickerBottomSheet(
|
|||||||
|
|
||||||
// Отслеживаем velocity для плавного snap
|
// Отслеживаем velocity для плавного snap
|
||||||
var lastDragVelocity by remember { mutableFloatStateOf(0f) }
|
var lastDragVelocity by remember { mutableFloatStateOf(0f) }
|
||||||
var dragSnapJob by remember { mutableStateOf<Job?>(null) }
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -742,9 +753,16 @@ fun MediaPickerBottomSheet(
|
|||||||
) { /* Prevent click through */ }
|
) { /* Prevent click through */ }
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
detectVerticalDragGestures(
|
detectVerticalDragGestures(
|
||||||
|
onDragStart = {
|
||||||
|
animationScope.launch { sheetHeightPx.stop() }
|
||||||
|
dragSheetHeightPx = interactiveSheetHeightPx
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
snapToNearestState()
|
||||||
|
lastDragVelocity = 0f
|
||||||
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
// Snap с учётом velocity
|
// Snap с учётом velocity
|
||||||
dragSnapJob?.cancel()
|
|
||||||
snapToNearestState(lastDragVelocity)
|
snapToNearestState(lastDragVelocity)
|
||||||
lastDragVelocity = 0f
|
lastDragVelocity = 0f
|
||||||
},
|
},
|
||||||
@@ -756,12 +774,9 @@ fun MediaPickerBottomSheet(
|
|||||||
lastDragVelocity = adjustedDragAmount * 1.8f
|
lastDragVelocity = adjustedDragAmount * 1.8f
|
||||||
|
|
||||||
// 🔥 Меняем высоту в реальном времени
|
// 🔥 Меняем высоту в реальном времени
|
||||||
val newHeight = (sheetHeightPx.value - adjustedDragAmount)
|
val newHeight = (interactiveSheetHeightPx - adjustedDragAmount)
|
||||||
.coerceIn(minHeightPx, expandedHeightPx)
|
.coerceIn(minHeightPx, expandedHeightPx)
|
||||||
dragSnapJob?.cancel()
|
dragSheetHeightPx = newHeight
|
||||||
dragSnapJob = animationScope.launch {
|
|
||||||
sheetHeightPx.snapTo(newHeight)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -809,7 +824,7 @@ fun MediaPickerBottomSheet(
|
|||||||
// Header height is DERIVED from how much the sheet has grown
|
// Header height is DERIVED from how much the sheet has grown
|
||||||
// beyond collapsedStateHeightPx — perfectly in sync, no jerk.
|
// beyond collapsedStateHeightPx — perfectly in sync, no jerk.
|
||||||
val selectionHeaderHeightPx = if (!isExpanded) {
|
val selectionHeaderHeightPx = if (!isExpanded) {
|
||||||
(sheetHeightPx.value - collapsedStateHeightPx)
|
(interactiveSheetHeightPx - collapsedStateHeightPx)
|
||||||
.coerceIn(0f, selectionHeaderExtraPx)
|
.coerceIn(0f, selectionHeaderExtraPx)
|
||||||
} else 0f
|
} else 0f
|
||||||
val selectionHeaderHeightDp = with(density) { selectionHeaderHeightPx.toDp() }
|
val selectionHeaderHeightDp = with(density) { selectionHeaderHeightPx.toDp() }
|
||||||
|
|||||||
@@ -88,9 +88,17 @@ fun ProfilePhotoPicker(
|
|||||||
var shouldShow by remember { mutableStateOf(false) }
|
var shouldShow by remember { mutableStateOf(false) }
|
||||||
var isClosing by remember { mutableStateOf(false) }
|
var isClosing by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Animatable для высоты sheet с drag support
|
var dragSheetOffsetPx by remember { mutableFloatStateOf(0f) }
|
||||||
val sheetHeightPx = remember { Animatable(0f) }
|
var isDraggingSheet by remember { mutableStateOf(false) }
|
||||||
val targetHeightPx = screenHeightPx * 0.92f // 92% экрана
|
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
|
// Permission launcher
|
||||||
val permissionLauncher = rememberLauncherForActivityResult(
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
@@ -140,9 +148,8 @@ fun ProfilePhotoPicker(
|
|||||||
if (isClosing) {
|
if (isClosing) {
|
||||||
isClosing = false
|
isClosing = false
|
||||||
shouldShow = false
|
shouldShow = false
|
||||||
scope.launch {
|
dragSheetOffsetPx = 0f
|
||||||
sheetHeightPx.snapTo(0f)
|
isDraggingSheet = false
|
||||||
}
|
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -164,7 +171,8 @@ fun ProfilePhotoPicker(
|
|||||||
if (isVisible) {
|
if (isVisible) {
|
||||||
shouldShow = true
|
shouldShow = true
|
||||||
isClosing = false
|
isClosing = false
|
||||||
sheetHeightPx.snapTo(targetHeightPx)
|
dragSheetOffsetPx = 0f
|
||||||
|
isDraggingSheet = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,24 +243,32 @@ fun ProfilePhotoPicker(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.fillMaxHeight(0.92f)
|
.fillMaxHeight(0.92f)
|
||||||
.offset {
|
.offset {
|
||||||
IntOffset(0, (screenHeightPx * animatedOffset).roundToInt())
|
IntOffset(
|
||||||
|
0,
|
||||||
|
(screenHeightPx * animatedOffset + animatedDragSheetOffsetPx).roundToInt()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
|
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
// Drag to dismiss
|
// Drag to dismiss
|
||||||
var totalDrag = 0f
|
|
||||||
detectVerticalDragGestures(
|
detectVerticalDragGestures(
|
||||||
onDragStart = { totalDrag = 0f },
|
onDragStart = { isDraggingSheet = true },
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
if (totalDrag > 150f) {
|
isDraggingSheet = false
|
||||||
|
if (dragSheetOffsetPx > screenHeightPx * 0.16f) {
|
||||||
animatedClose()
|
animatedClose()
|
||||||
|
} else {
|
||||||
|
dragSheetOffsetPx = 0f
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDragCancel = { totalDrag = 0f },
|
onDragCancel = {
|
||||||
onVerticalDrag = { _, dragAmount ->
|
isDraggingSheet = false
|
||||||
if (dragAmount > 0) { // Only downward
|
dragSheetOffsetPx = 0f
|
||||||
totalDrag += dragAmount
|
},
|
||||||
}
|
onVerticalDrag = { change, dragAmount ->
|
||||||
|
change.consume()
|
||||||
|
dragSheetOffsetPx =
|
||||||
|
(dragSheetOffsetPx + dragAmount).coerceIn(0f, screenHeightPx)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user