v1.2.4: фиксы медиапикера, файловых загрузок и UI групп
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user