v1.2.4: фиксы медиапикера, файловых загрузок и UI групп

This commit is contained in:
2026-03-20 14:29:12 +05:00
parent 0353f845a5
commit 4440016d5f
11 changed files with 661 additions and 339 deletions

View File

@@ -348,13 +348,17 @@ fun ChatDetailScreen(
var simplePickerPreviewGalleryUris by remember { mutableStateOf<List<Uri>>(emptyList()) }
var simplePickerPreviewInitialIndex by remember { mutableIntStateOf(0) }
// 🎨 Управление статус баром — ВСЕГДА чёрные иконки в светлой теме
// 🎨 Управление статус баром.
// Важно: когда открыт media/camera overlay, статус-баром управляет сам overlay.
// Иначе ChatDetail может перетереть затемнение пикера в прозрачный.
if (!view.isInEditMode) {
SideEffect {
if (showImageViewer) {
SystemBarsStyleUtils.applyFullscreenDark(window, view)
} else {
if (window != null && view != null) {
val isOverlayControllingSystemBars = showMediaPicker
if (!isOverlayControllingSystemBars && window != null && view != null) {
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
window.statusBarColor = android.graphics.Color.TRANSPARENT
ic.isAppearanceLightStatusBars = false

View File

@@ -483,6 +483,9 @@ fun ChatsListScreen(
it.status ==
com.rosetta.messenger.network.FileDownloadStatus
.DOWNLOADING ||
it.status ==
com.rosetta.messenger.network.FileDownloadStatus
.PAUSED ||
it.status ==
com.rosetta.messenger.network.FileDownloadStatus
.DECRYPTING
@@ -5057,6 +5060,7 @@ private fun formatDownloadStatusText(
return when (item.status) {
com.rosetta.messenger.network.FileDownloadStatus.QUEUED -> "Queued"
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> "Downloading $percent%"
com.rosetta.messenger.network.FileDownloadStatus.PAUSED -> "Paused $percent%"
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING -> "Decrypting"
com.rosetta.messenger.network.FileDownloadStatus.DONE -> "Completed"
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> "Download failed"

View File

@@ -62,6 +62,7 @@ internal fun MediaGrid(
mediaItems: List<MediaItem>,
selectedItemOrder: List<Long>,
showCameraItem: Boolean = true,
cameraEnabled: Boolean = true,
gridState: LazyGridState = rememberLazyGridState(),
onCameraClick: () -> Unit,
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
@@ -87,6 +88,7 @@ internal fun MediaGrid(
item(key = "camera_button") {
CameraGridItem(
onClick = onCameraClick,
enabled = cameraEnabled,
isDarkTheme = isDarkTheme
)
}
@@ -117,6 +119,7 @@ internal fun MediaGrid(
@Composable
internal fun CameraGridItem(
onClick: () -> Unit,
enabled: Boolean = true,
isDarkTheme: Boolean
) {
val context = LocalContext.current
@@ -147,7 +150,9 @@ internal fun CameraGridItem(
) == PackageManager.PERMISSION_GRANTED
}
DisposableEffect(lifecycleOwner, hasCameraPermission) {
val enabledState = rememberUpdatedState(enabled)
DisposableEffect(lifecycleOwner, hasCameraPermission, enabled) {
onDispose {
val provider = cameraProvider
val preview = previewUseCase
@@ -167,10 +172,10 @@ internal fun CameraGridItem(
.aspectRatio(1f)
.clip(RoundedCornerShape(4.dp))
.background(backgroundColor)
.clickable(onClick = onClick),
.clickable(enabled = enabled, onClick = onClick),
contentAlignment = Alignment.Center
) {
if (hasCameraPermission) {
if (hasCameraPermission && enabled) {
AndroidView(
factory = { ctx ->
val previewView = PreviewView(ctx).apply {
@@ -181,6 +186,9 @@ internal fun CameraGridItem(
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
cameraProviderFuture.addListener({
try {
if (!enabledState.value) {
return@addListener
}
val provider = cameraProviderFuture.get()
cameraProvider = provider
val preview = Preview.Builder().build().also {

View File

@@ -32,6 +32,7 @@ import androidx.compose.material3.Icon
internal fun AttachAlertPhotoLayout(
state: AttachAlertUiState,
gridState: LazyGridState,
cameraEnabled: Boolean = true,
onCameraClick: () -> Unit,
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
onItemCheckClick: (MediaItem) -> Unit,
@@ -89,6 +90,7 @@ internal fun AttachAlertPhotoLayout(
mediaItems = state.visibleMediaItems,
selectedItemOrder = state.selectedItemOrder,
showCameraItem = state.visibleAlbum?.isAllMedia != false,
cameraEnabled = cameraEnabled,
gridState = gridState,
onCameraClick = onCameraClick,
onItemClick = onItemClick,

View File

@@ -122,13 +122,6 @@ private fun updatePopupImeFocusable(rootView: View, imeFocusable: Boolean) {
}
}
private data class PickerSystemBarsSnapshot(
val scrimAlpha: Float,
val isFullScreen: Boolean,
val isDarkTheme: Boolean,
val openProgress: Float
)
/**
* Telegram-style attach alert (media picker bottom sheet).
*
@@ -741,52 +734,49 @@ fun ChatAttachAlert(
}
}
LaunchedEffect(shouldShow, state.editingItem) {
LaunchedEffect(
shouldShow,
state.editingItem,
isPickerFullScreen,
isDarkTheme,
hasNativeNavigationBar
) {
if (!shouldShow || state.editingItem != null) return@LaunchedEffect
val window = (view.context as? Activity)?.window ?: return@LaunchedEffect
snapshotFlow {
PickerSystemBarsSnapshot(
scrimAlpha = scrimAlpha,
isFullScreen = isPickerFullScreen,
isDarkTheme = isDarkTheme,
openProgress = (1f - animatedOffset).coerceIn(0f, 1f)
)
}.collect { state ->
val alpha = state.scrimAlpha
val fullScreen = state.isFullScreen
val dark = state.isDarkTheme
if (fullScreen) {
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
insetsController?.isAppearanceLightStatusBars = !dark
} else {
// Apply scrim to status bar so it matches the overlay darkness
val scrimInt = (alpha * 255).toInt().coerceIn(0, 255)
window.statusBarColor = android.graphics.Color.argb(scrimInt, 0, 0, 0)
insetsController?.isAppearanceLightStatusBars = false
}
if (hasNativeNavigationBar) {
// Telegram-like: for 2/3-button navigation keep picker-surface tint.
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
window.navigationBarColor = android.graphics.Color.argb(
navAlpha,
android.graphics.Color.red(navBaseColor),
android.graphics.Color.green(navBaseColor),
android.graphics.Color.blue(navBaseColor)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = true
}
insetsController?.isAppearanceLightNavigationBars = !dark
} else {
// Telegram-like on gesture navigation: transparent stable nav area.
window.navigationBarColor = android.graphics.Color.TRANSPARENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
insetsController?.isAppearanceLightNavigationBars = !dark
}
val dark = isDarkTheme
val fullScreen = isPickerFullScreen
if (hasNativeNavigationBar) {
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
window.navigationBarColor = navBaseColor
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = true
}
insetsController?.isAppearanceLightNavigationBars = !dark
} else {
window.navigationBarColor = android.graphics.Color.TRANSPARENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
insetsController?.isAppearanceLightNavigationBars = !dark
}
// Telegram-like natural dim: status-bar tint follows the same scrim alpha
// as the popup overlay, so top area and content overlay always match.
if (fullScreen) {
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
insetsController?.isAppearanceLightStatusBars = !dark
} else {
insetsController?.isAppearanceLightStatusBars = false
var lastAppliedAlpha = -1
snapshotFlow { (scrimAlpha * 255f).toInt().coerceIn(0, 255) }
.collect { alpha ->
if (alpha != lastAppliedAlpha) {
lastAppliedAlpha = alpha
window.statusBarColor = android.graphics.Color.argb(alpha, 0, 0, 0)
}
}
}
}
// ═══════════════════════════════════════════════════════════
@@ -1062,6 +1052,7 @@ fun ChatAttachAlert(
AttachAlertTab.PHOTO -> AttachAlertPhotoLayout(
state = state,
gridState = mediaGridState,
cameraEnabled = !isClosing,
onCameraClick = {
requestClose {
hideKeyboard()

View File

@@ -16,6 +16,7 @@ import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.ExperimentalFoundationApi
@@ -33,20 +34,26 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.exifinterface.media.ExifInterface
import com.rosetta.messenger.R
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.network.AttachmentType
@@ -68,6 +75,7 @@ import kotlinx.coroutines.withContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.content.FileProvider
import android.content.Intent
import android.os.SystemClock
import android.webkit.MimeTypeMap
import java.io.ByteArrayInputStream
import java.io.File
@@ -333,6 +341,105 @@ enum class DownloadStatus {
ERROR
}
private enum class TelegramFileActionState {
FILE,
DOWNLOAD,
CANCEL,
PAUSE,
ERROR
}
@Composable
private fun TelegramFileActionButton(
state: TelegramFileActionState,
progress: Float?,
indeterminate: Boolean,
modifier: Modifier = Modifier
) {
val spinTransition = rememberInfiniteTransition(label = "file_action_spin")
val spin by spinTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "file_action_spin_progress"
)
val backgroundColor = if (state == TelegramFileActionState.ERROR) Color(0xFFE53935) else PrimaryBlue
val iconPainter = when (state) {
TelegramFileActionState.FILE -> TelegramIcons.File
TelegramFileActionState.DOWNLOAD -> painterResource(R.drawable.msg_download)
TelegramFileActionState.CANCEL -> TelegramIcons.Close
TelegramFileActionState.ERROR -> TelegramIcons.Close
TelegramFileActionState.PAUSE -> null
}
val iconSize =
when (state) {
TelegramFileActionState.ERROR -> 18.dp
TelegramFileActionState.PAUSE -> 18.dp
else -> 20.dp
}
val showProgressRing =
(state == TelegramFileActionState.PAUSE ||
state == TelegramFileActionState.DOWNLOAD ||
state == TelegramFileActionState.CANCEL) &&
(indeterminate || progress != null)
val sweep = when {
indeterminate -> 104f
progress != null -> (progress.coerceIn(0f, 1f) * 360f)
else -> 0f
}
val startAngle = if (indeterminate) (spin * 360f) - 90f else -90f
Box(
modifier = modifier.size(40.dp),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
.background(backgroundColor),
contentAlignment = Alignment.Center
) {
if (showProgressRing && sweep > 0f) {
Canvas(modifier = Modifier.fillMaxSize()) {
val strokeWidth = 2.dp.toPx()
val inset = 3.dp.toPx()
drawArc(
color = Color.White,
startAngle = startAngle,
sweepAngle = sweep,
useCenter = false,
topLeft = Offset(inset, inset),
size = Size(size.width - inset * 2f, size.height - inset * 2f),
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
)
}
}
if (state == TelegramFileActionState.PAUSE) {
Icon(
imageVector = Icons.Default.Pause,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(iconSize)
)
} else {
Icon(
painter = iconPainter ?: TelegramIcons.File,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(iconSize)
)
}
}
}
}
/**
* Composable для отображения всех attachments в сообщении 🖼️ IMAGE attachments группируются в
* коллаж (как в Telegram)
@@ -1454,6 +1561,8 @@ fun FileAttachment(
val context = LocalContext.current
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
var downloadProgress by remember { mutableStateOf(0f) }
var isPaused by remember { mutableStateOf(false) }
var lastActionAtMs by remember { mutableLongStateOf(0L) }
// Bounce animation for icon
val iconScale = remember { Animatable(0f) }
@@ -1495,16 +1604,40 @@ fun FileAttachment(
downloadStatus = when (state.status) {
com.rosetta.messenger.network.FileDownloadStatus.QUEUED,
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> DownloadStatus.DOWNLOADING
com.rosetta.messenger.network.FileDownloadStatus.PAUSED -> DownloadStatus.DOWNLOADING
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING -> DownloadStatus.DECRYPTING
com.rosetta.messenger.network.FileDownloadStatus.DONE -> DownloadStatus.DOWNLOADED
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> DownloadStatus.ERROR
}
isPaused = state.status == com.rosetta.messenger.network.FileDownloadStatus.PAUSED
}
LaunchedEffect(attachment.id) {
val existingState = com.rosetta.messenger.network.FileDownloadManager.stateOf(attachment.id)
if (existingState != null) {
downloadProgress = existingState.progress
downloadStatus = when (existingState.status) {
com.rosetta.messenger.network.FileDownloadStatus.QUEUED,
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING ->
DownloadStatus.DOWNLOADING
com.rosetta.messenger.network.FileDownloadStatus.PAUSED ->
DownloadStatus.DOWNLOADING
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING ->
DownloadStatus.DECRYPTING
com.rosetta.messenger.network.FileDownloadStatus.DONE ->
DownloadStatus.DOWNLOADED
com.rosetta.messenger.network.FileDownloadStatus.ERROR ->
DownloadStatus.ERROR
}
isPaused =
existingState.status == com.rosetta.messenger.network.FileDownloadStatus.PAUSED
return@LaunchedEffect
}
// Если менеджер уже качает этот файл — подхватим состояние оттуда
if (com.rosetta.messenger.network.FileDownloadManager.isDownloading(attachment.id)) {
downloadStatus = DownloadStatus.DOWNLOADING
isPaused = false
return@LaunchedEffect
}
downloadStatus = if (isDownloadTag(preview)) {
@@ -1516,6 +1649,7 @@ fun FileAttachment(
if (savedFile.exists()) DownloadStatus.DOWNLOADED
else DownloadStatus.DOWNLOADED // blob есть в памяти
}
isPaused = false
}
// Открыть файл через системное приложение
@@ -1551,10 +1685,8 @@ fun FileAttachment(
// 📥 Запуск скачивания через глобальный FileDownloadManager
val download: () -> Unit = {
if (downloadTag.isNotEmpty()) {
downloadStatus = DownloadStatus.DOWNLOADING
downloadProgress = 0f
isPaused = false
com.rosetta.messenger.network.FileDownloadManager.download(
context = context,
attachmentId = attachment.id,
downloadTag = downloadTag,
chachaKey = chachaKey,
@@ -1566,19 +1698,56 @@ fun FileAttachment(
}
}
val pauseDownload: () -> Unit = {
com.rosetta.messenger.network.FileDownloadManager.pause(attachment.id)
isPaused = true
}
val resumeDownload: () -> Unit = {
isPaused = false
com.rosetta.messenger.network.FileDownloadManager.resume(attachment.id)
}
val isSendingUpload = isOutgoing && messageStatus == MessageStatus.SENDING
val isDownloadInProgress =
!isPaused &&
(downloadStatus == DownloadStatus.DOWNLOADING ||
downloadStatus == DownloadStatus.DECRYPTING)
val actionState = when {
downloadStatus == DownloadStatus.ERROR -> TelegramFileActionState.ERROR
isSendingUpload -> TelegramFileActionState.CANCEL
isDownloadInProgress -> TelegramFileActionState.PAUSE
isPaused -> TelegramFileActionState.DOWNLOAD
downloadStatus == DownloadStatus.NOT_DOWNLOADED -> TelegramFileActionState.DOWNLOAD
else -> TelegramFileActionState.FILE
}
val actionProgress = if (isDownloadInProgress || isPaused) animatedProgress else null
// Telegram-style файл - как в desktop: без внутреннего фона, просто иконка + текст
Box(modifier = Modifier.fillMaxWidth()) {
Row(
modifier =
Modifier.fillMaxWidth()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled =
downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
downloadStatus == DownloadStatus.DOWNLOADED ||
downloadStatus == DownloadStatus.ERROR
!isSendingUpload &&
(downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
downloadStatus == DownloadStatus.DOWNLOADED ||
downloadStatus == DownloadStatus.ERROR ||
isDownloadInProgress ||
isPaused)
) {
when (downloadStatus) {
DownloadStatus.DOWNLOADED -> openFile()
val now = SystemClock.elapsedRealtime()
if (now - lastActionAtMs < 220L) return@clickable
lastActionAtMs = now
when {
isPaused -> resumeDownload()
downloadStatus == DownloadStatus.DOWNLOADING ||
downloadStatus == DownloadStatus.DECRYPTING -> pauseDownload()
downloadStatus == DownloadStatus.DOWNLOADED -> openFile()
else -> download()
}
}
@@ -1595,62 +1764,12 @@ fun FileAttachment(
},
contentAlignment = Alignment.Center
) {
// Круглый фон иконки
Box(
modifier =
Modifier.fillMaxSize()
.clip(CircleShape)
.background(
if (downloadStatus == DownloadStatus.ERROR)
Color(0xFFE53935)
else PrimaryBlue
),
contentAlignment = Alignment.Center
) {
when (downloadStatus) {
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
// Determinate progress like Telegram
CircularProgressIndicator(
progress = downloadProgress.coerceIn(0f, 1f),
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp
)
}
DownloadStatus.NOT_DOWNLOADED -> {
Icon(
Icons.Default.ArrowDownward,
contentDescription = "Download",
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
DownloadStatus.DOWNLOADED -> {
Icon(
Icons.Default.InsertDriveFile,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
DownloadStatus.ERROR -> {
Icon(
Icons.Default.Close,
contentDescription = "Error",
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
else -> {
Icon(
Icons.Default.InsertDriveFile,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
}
}
TelegramFileActionButton(
state = actionState,
progress = actionProgress,
indeterminate = isSendingUpload,
modifier = Modifier.fillMaxSize()
)
}
Spacer(modifier = Modifier.width(10.dp))
@@ -1679,34 +1798,52 @@ fun FileAttachment(
PrimaryBlue
}
when (downloadStatus) {
DownloadStatus.DOWNLOADING -> {
// Telegram-style: "1.2 MB / 5.4 MB"
// CDN download maps to progress 0..0.8
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
val downloadedBytes = (cdnFraction * fileSize).toLong()
Text(
text = "${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
fontSize = 12.sp,
color = statusColor
)
}
DownloadStatus.DECRYPTING -> {
AnimatedDotsText(
baseText = "Decrypting",
color = statusColor,
fontSize = 12.sp
)
}
else -> {
Text(
text = when (downloadStatus) {
DownloadStatus.ERROR -> "File expired"
else -> "${formatFileSize(fileSize)} $fileExtension"
},
fontSize = 12.sp,
color = statusColor
)
if (isSendingUpload) {
AnimatedDotsText(
baseText = "Uploading",
color = statusColor,
fontSize = 12.sp
)
} else {
when (downloadStatus) {
DownloadStatus.DOWNLOADING -> {
if (isPaused) {
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
val downloadedBytes = (cdnFraction * fileSize).toLong()
Text(
text = "Paused • ${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
fontSize = 12.sp,
color = statusColor
)
} else {
// Telegram-style: "1.2 MB / 5.4 MB"
// CDN download maps to progress 0..0.8
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
val downloadedBytes = (cdnFraction * fileSize).toLong()
Text(
text = "${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
fontSize = 12.sp,
color = statusColor
)
}
}
DownloadStatus.DECRYPTING -> {
AnimatedDotsText(
baseText = "Decrypting",
color = statusColor,
fontSize = 12.sp
)
}
else -> {
Text(
text = when (downloadStatus) {
DownloadStatus.ERROR -> "File expired"
else -> "${formatFileSize(fileSize)} $fileExtension"
},
fontSize = 12.sp,
color = statusColor
)
}
}
}
}

View File

@@ -858,8 +858,14 @@ fun MessageBubble(
),
verticalAlignment = Alignment.CenterVertically
) {
val senderLabelText =
senderName
.replace('\n', ' ')
.trim()
val senderLabelMaxWidth =
if (isGroupSenderAdmin) 170.dp else 220.dp
Text(
text = senderName,
text = senderLabelText,
color =
groupSenderLabelColor(
senderPublicKey,
@@ -867,6 +873,7 @@ fun MessageBubble(
),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.widthIn(max = senderLabelMaxWidth),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)

View File

@@ -82,13 +82,6 @@ import kotlin.math.roundToInt
private const val TAG = "MediaPickerBottomSheet"
private const val ALL_MEDIA_ALBUM_ID = 0L
private data class PickerSystemBarsSnapshot(
val scrimAlpha: Float,
val isFullScreen: Boolean,
val isDarkTheme: Boolean,
val openProgress: Float
)
/**
* Media item from gallery
*/
@@ -606,56 +599,50 @@ fun MediaPickerBottomSheet(
}
}
// Reactive updates — single snapshotFlow drives ALL system bar colors
LaunchedEffect(shouldShow, editingItem) {
// Telegram-style: system bar updates only by picker state,
// no per-frame status bar color animation.
LaunchedEffect(
shouldShow,
editingItem,
isPickerFullScreen,
isDarkTheme,
hasNativeNavigationBar
) {
if (!shouldShow || editingItem != null) return@LaunchedEffect
val window = (view.context as? android.app.Activity)?.window ?: return@LaunchedEffect
val dark = isDarkTheme
val fullScreen = isPickerFullScreen
snapshotFlow {
PickerSystemBarsSnapshot(
scrimAlpha = scrimAlpha,
isFullScreen = isPickerFullScreen,
isDarkTheme = isDarkTheme,
openProgress = (1f - animatedOffset).coerceIn(0f, 1f)
)
}.collect { state ->
val alpha = state.scrimAlpha
val fullScreen = state.isFullScreen
val dark = state.isDarkTheme
if (fullScreen) {
// Full screen: status bar = picker background, seamless
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
insetsController?.isAppearanceLightStatusBars = !dark
} else {
// Collapsed: semi-transparent scrim
window.statusBarColor = android.graphics.Color.argb(
(alpha * 255).toInt().coerceIn(0, 255), 0, 0, 0
)
insetsController?.isAppearanceLightStatusBars = false
}
if (hasNativeNavigationBar) {
// Telegram-like: for 2/3-button navigation keep picker-surface tint.
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
window.navigationBarColor = android.graphics.Color.argb(
navAlpha,
android.graphics.Color.red(navBaseColor),
android.graphics.Color.green(navBaseColor),
android.graphics.Color.blue(navBaseColor)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = true
}
insetsController?.isAppearanceLightNavigationBars = !dark
} else {
// Telegram-like on gesture navigation: transparent stable nav area.
window.navigationBarColor = android.graphics.Color.TRANSPARENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
insetsController?.isAppearanceLightNavigationBars = !dark
}
if (hasNativeNavigationBar) {
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
window.navigationBarColor = navBaseColor
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = true
}
insetsController?.isAppearanceLightNavigationBars = !dark
} else {
window.navigationBarColor = android.graphics.Color.TRANSPARENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
insetsController?.isAppearanceLightNavigationBars = !dark
}
// Telegram-like natural dim: status-bar tint follows picker scrim alpha.
if (fullScreen) {
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
insetsController?.isAppearanceLightStatusBars = !dark
} else {
insetsController?.isAppearanceLightStatusBars = false
var lastAppliedAlpha = -1
snapshotFlow { (scrimAlpha * 255f).toInt().coerceIn(0, 255) }
.collect { alpha ->
if (alpha != lastAppliedAlpha) {
lastAppliedAlpha = alpha
window.statusBarColor = android.graphics.Color.argb(alpha, 0, 0, 0)
}
}
}
}
// Используем Popup для показа поверх клавиатуры
@@ -1047,6 +1034,7 @@ fun MediaPickerBottomSheet(
mediaItems = visibleMediaItems,
selectedItemOrder = selectedItemOrder,
showCameraItem = selectedAlbum?.isAllMedia != false,
cameraEnabled = !isClosing,
gridState = mediaGridState,
onCameraClick = {
requestClose {
@@ -1659,6 +1647,7 @@ private fun MediaGrid(
mediaItems: List<MediaItem>,
selectedItemOrder: List<Long>,
showCameraItem: Boolean = true,
cameraEnabled: Boolean = true,
gridState: LazyGridState = rememberLazyGridState(),
onCameraClick: () -> Unit,
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
@@ -1685,6 +1674,7 @@ private fun MediaGrid(
item(key = "camera_button") {
CameraGridItem(
onClick = onCameraClick,
enabled = cameraEnabled,
isDarkTheme = isDarkTheme
)
}
@@ -1715,6 +1705,7 @@ private fun MediaGrid(
@Composable
private fun CameraGridItem(
onClick: () -> Unit,
enabled: Boolean = true,
isDarkTheme: Boolean
) {
val context = LocalContext.current
@@ -1753,7 +1744,9 @@ private fun CameraGridItem(
) == PackageManager.PERMISSION_GRANTED
}
DisposableEffect(lifecycleOwner, hasCameraPermission) {
val enabledState = rememberUpdatedState(enabled)
DisposableEffect(lifecycleOwner, hasCameraPermission, enabled) {
onDispose {
val provider = cameraProvider
val preview = previewUseCase
@@ -1773,10 +1766,10 @@ private fun CameraGridItem(
.aspectRatio(1f)
.clip(RoundedCornerShape(4.dp))
.background(backgroundColor)
.clickable(onClick = onClick),
.clickable(enabled = enabled, onClick = onClick),
contentAlignment = Alignment.Center
) {
if (hasCameraPermission) {
if (hasCameraPermission && enabled) {
// Show live camera preview
AndroidView(
factory = { ctx ->
@@ -1788,6 +1781,9 @@ private fun CameraGridItem(
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
cameraProviderFuture.addListener({
try {
if (!enabledState.value) {
return@addListener
}
val provider = cameraProviderFuture.get()
cameraProvider = provider