From 4440016d5fb1c447098860bfde505897f29155ce Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 20 Mar 2026 14:29:12 +0500 Subject: [PATCH] =?UTF-8?q?v1.2.4:=20=D1=84=D0=B8=D0=BA=D1=81=D1=8B=20?= =?UTF-8?q?=D0=BC=D0=B5=D0=B4=D0=B8=D0=B0=D0=BF=D0=B8=D0=BA=D0=B5=D1=80?= =?UTF-8?q?=D0=B0,=20=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2=D1=8B=D1=85=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BE=D0=BA=20=D0=B8=20UI?= =?UTF-8?q?=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rosetta/messenger/data/ReleaseNotes.kt | 15 + .../messenger/network/FileDownloadManager.kt | 357 ++++++++++++------ .../messenger/network/TransportManager.kt | 71 ++-- .../messenger/ui/chats/ChatDetailScreen.kt | 8 +- .../messenger/ui/chats/ChatsListScreen.kt | 4 + .../ui/chats/attach/AttachAlertComponents.kt | 14 +- .../ui/chats/attach/AttachAlertPhotoLayout.kt | 2 + .../ui/chats/attach/ChatAttachAlert.kt | 91 ++--- .../chats/components/AttachmentComponents.kt | 321 +++++++++++----- .../chats/components/ChatDetailComponents.kt | 9 +- .../components/MediaPickerBottomSheet.kt | 108 +++--- 11 files changed, 661 insertions(+), 339 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt index 411788f..3e41b8d 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -44,6 +44,21 @@ object ReleaseNotes { - На экране группы выровнены размеры иконок Encryption Key и Add Members - Улучшен back-свайп на экране Encryption Key: возврат во внутреннюю страницу группы - Приведён к нормальному размер индикатор ошибки в чат-листе + + Медиапикер и камера + - Исправлено затемнение статус-бара при открытии медиапикера: больше не пропадает при активации камеры + - Переработано управление системными барами в attach picker и media picker для более естественного Telegram-поведения + - Камера в медиапикере теперь корректно блокируется во время закрытия, запись не стартует в момент dismiss + + Файлы и загрузки + - Добавлена пауза и возобновление скачивания файлов прямо из пузырька (иконка pause/resume) + - Устранено дёргание прогресса при быстрых тапах по скачивающемуся файлу + - Прогресс скачивания стал стабильным и не откатывается назад после pause/resume + - Обновлён экран активных загрузок: добавлен статус Paused + + Групповые сообщения + - Добавлен truncate для длинных имён отправителей в групповых пузырьках + - Убраны переносы в имени отправителя в шапке группового сообщения """.trimIndent() fun getNotice(version: String): String = diff --git a/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt b/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt index ca873fd..09f8c46 100644 --- a/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt @@ -1,6 +1,5 @@ package com.rosetta.messenger.network -import android.content.Context import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.MessageCrypto import kotlinx.coroutines.* @@ -20,6 +19,7 @@ data class FileDownloadState( enum class FileDownloadStatus { QUEUED, DOWNLOADING, + PAUSED, DECRYPTING, DONE, ERROR @@ -35,6 +35,22 @@ object FileDownloadManager { /** Текущие Job'ы — чтобы не запускать повторно */ private val jobs = mutableMapOf() + /** Последние параметры скачивания — нужны для resume */ + private val requests = mutableMapOf() + /** Флаг, что cancel произошёл именно как user pause */ + private val pauseRequested = mutableSetOf() + /** Если пользователь очень быстро тапнул pause→resume, запускаем resume после фактической паузы */ + private val resumeAfterPause = mutableSetOf() + + private data class DownloadRequest( + val attachmentId: String, + val downloadTag: String, + val chachaKey: String, + val privateKey: String, + val accountPublicKey: String, + val fileName: String, + val savedFile: File + ) // ─── helpers ─── @@ -67,9 +83,16 @@ object FileDownloadManager { */ fun isDownloading(attachmentId: String): Boolean { val state = _downloads.value[attachmentId] ?: return false - return state.status == FileDownloadStatus.DOWNLOADING || state.status == FileDownloadStatus.DECRYPTING + return state.status == FileDownloadStatus.QUEUED || + state.status == FileDownloadStatus.DOWNLOADING || + state.status == FileDownloadStatus.DECRYPTING } + fun isPaused(attachmentId: String): Boolean = + _downloads.value[attachmentId]?.status == FileDownloadStatus.PAUSED + + fun stateOf(attachmentId: String): FileDownloadState? = _downloads.value[attachmentId] + /** * Возвращает Flow для конкретного attachment */ @@ -81,7 +104,6 @@ object FileDownloadManager { * Скачивание продолжается даже если пользователь вышел из чата. */ fun download( - context: Context, attachmentId: String, downloadTag: String, chachaKey: String, @@ -90,132 +112,232 @@ object FileDownloadManager { fileName: String, savedFile: File ) { - // Уже в процессе? - if (jobs[attachmentId]?.isActive == true) return - val normalizedAccount = accountPublicKey.trim() - val savedPath = savedFile.absolutePath + val request = DownloadRequest( + attachmentId = attachmentId, + downloadTag = downloadTag, + chachaKey = chachaKey, + privateKey = privateKey, + accountPublicKey = accountPublicKey.trim(), + fileName = fileName, + savedFile = savedFile + ) + requests[attachmentId] = request + startDownload(request) + } - update(attachmentId, fileName, FileDownloadStatus.QUEUED, 0f, normalizedAccount, savedPath) + fun pause(attachmentId: String) { + val current = _downloads.value[attachmentId] ?: return + if ( + current.status == FileDownloadStatus.DONE || + current.status == FileDownloadStatus.ERROR + ) return - jobs[attachmentId] = scope.launch { - try { - update( - attachmentId, - fileName, - FileDownloadStatus.DOWNLOADING, - 0f, - normalizedAccount, - savedPath - ) + pauseRequested.add(attachmentId) + val pausedProgress = current.progress.coerceIn(0f, 0.98f) + update( + id = attachmentId, + fileName = current.fileName, + status = FileDownloadStatus.PAUSED, + progress = pausedProgress, + accountPublicKey = current.accountPublicKey, + savedPath = current.savedPath + ) - // Запускаем polling прогресса из TransportManager - val progressJob = launch { - TransportManager.downloading.collect { list -> - val entry = list.find { it.id == attachmentId } - if (entry != null) { - // CDN progress: 0-100 → 0f..0.8f (80% круга — скачивание) - val p = (entry.progress / 100f) * 0.8f - update( - attachmentId, - fileName, - FileDownloadStatus.DOWNLOADING, - p, - normalizedAccount, - savedPath - ) - } - } - } + TransportManager.cancelDownload(attachmentId) + jobs[attachmentId]?.cancel() + } - val success = withContext(Dispatchers.IO) { - if (isGroupStoredKey(chachaKey)) { - downloadGroupFile( - attachmentId = attachmentId, - downloadTag = downloadTag, - chachaKey = chachaKey, - privateKey = privateKey, - fileName = fileName, - savedFile = savedFile, - accountPublicKey = normalizedAccount, - savedPath = savedPath - ) - } else { - downloadDirectFile( - attachmentId = attachmentId, - downloadTag = downloadTag, - chachaKey = chachaKey, - privateKey = privateKey, - fileName = fileName, - savedFile = savedFile, - accountPublicKey = normalizedAccount, - savedPath = savedPath - ) - } - } - - progressJob.cancel() - - if (success) { - update( - attachmentId, - fileName, - FileDownloadStatus.DONE, - 1f, - normalizedAccount, - savedPath - ) - } else { - update( - attachmentId, - fileName, - FileDownloadStatus.ERROR, - 0f, - normalizedAccount, - savedPath - ) - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - e.printStackTrace() - update( - attachmentId, - fileName, - FileDownloadStatus.ERROR, - 0f, - normalizedAccount, - savedPath - ) - } catch (_: OutOfMemoryError) { - System.gc() - update( - attachmentId, - fileName, - FileDownloadStatus.ERROR, - 0f, - normalizedAccount, - savedPath - ) - } finally { - jobs.remove(attachmentId) - // Автоочистка через 5 секунд после завершения - scope.launch { - delay(5000) - _downloads.update { it - attachmentId } - } - } + fun resume(attachmentId: String) { + val request = requests[attachmentId] ?: return + if (jobs[attachmentId]?.isActive == true) { + resumeAfterPause.add(attachmentId) + return } + pauseRequested.remove(attachmentId) + startDownload(request) } /** * Отменяет скачивание */ fun cancel(attachmentId: String) { + pauseRequested.remove(attachmentId) + resumeAfterPause.remove(attachmentId) + requests.remove(attachmentId) + TransportManager.cancelDownload(attachmentId) jobs[attachmentId]?.cancel() jobs.remove(attachmentId) _downloads.update { it - attachmentId } } + private fun startDownload(request: DownloadRequest) { + val attachmentId = request.attachmentId + if (jobs[attachmentId]?.isActive == true) return + + pauseRequested.remove(attachmentId) + + val savedPath = request.savedFile.absolutePath + val resumeBase = + (_downloads.value[attachmentId] + ?.takeIf { it.status == FileDownloadStatus.PAUSED } + ?.progress + ?: 0f).coerceIn(0f, 0.8f) + + update( + attachmentId, + request.fileName, + FileDownloadStatus.QUEUED, + resumeBase, + request.accountPublicKey, + savedPath + ) + + jobs[attachmentId] = scope.launch { + var progressJob: Job? = null + try { + update( + attachmentId, + request.fileName, + FileDownloadStatus.DOWNLOADING, + resumeBase, + request.accountPublicKey, + savedPath + ) + + // Запускаем polling прогресса из TransportManager. + // При resume удерживаем плавный прогресс без визуального отката назад. + progressJob = launch { + TransportManager.downloading.collect { list -> + val entry = list.find { it.id == attachmentId } ?: return@collect + val rawCdn = (entry.progress / 100f) * 0.8f + val mapped = if (resumeBase > 0f) { + val normalized = (rawCdn / 0.8f).coerceIn(0f, 1f) + resumeBase + (0.8f - resumeBase) * normalized + } else { + rawCdn + } + val current = _downloads.value[attachmentId]?.progress ?: 0f + val stable = maxOf(current, mapped).coerceIn(0f, 0.8f) + update( + attachmentId, + request.fileName, + FileDownloadStatus.DOWNLOADING, + stable, + request.accountPublicKey, + savedPath + ) + } + } + + val success = withContext(Dispatchers.IO) { + if (isGroupStoredKey(request.chachaKey)) { + downloadGroupFile( + attachmentId = attachmentId, + downloadTag = request.downloadTag, + chachaKey = request.chachaKey, + privateKey = request.privateKey, + fileName = request.fileName, + savedFile = request.savedFile, + accountPublicKey = request.accountPublicKey, + savedPath = savedPath + ) + } else { + downloadDirectFile( + attachmentId = attachmentId, + downloadTag = request.downloadTag, + chachaKey = request.chachaKey, + privateKey = request.privateKey, + fileName = request.fileName, + savedFile = request.savedFile, + accountPublicKey = request.accountPublicKey, + savedPath = savedPath + ) + } + } + + if (success) { + update( + attachmentId, + request.fileName, + FileDownloadStatus.DONE, + 1f, + request.accountPublicKey, + savedPath + ) + } else { + update( + attachmentId, + request.fileName, + FileDownloadStatus.ERROR, + 0f, + request.accountPublicKey, + savedPath + ) + } + } catch (e: CancellationException) { + if (pauseRequested.remove(attachmentId)) { + val current = _downloads.value[attachmentId] + val pausedProgress = (current?.progress ?: resumeBase).coerceIn(0f, 0.98f) + update( + attachmentId, + request.fileName, + FileDownloadStatus.PAUSED, + pausedProgress, + request.accountPublicKey, + savedPath + ) + } else { + throw e + } + } catch (e: Exception) { + e.printStackTrace() + update( + attachmentId, + request.fileName, + FileDownloadStatus.ERROR, + 0f, + request.accountPublicKey, + savedPath + ) + } catch (_: OutOfMemoryError) { + System.gc() + update( + attachmentId, + request.fileName, + FileDownloadStatus.ERROR, + 0f, + request.accountPublicKey, + savedPath + ) + } finally { + progressJob?.cancel() + jobs.remove(attachmentId) + + if (resumeAfterPause.remove(attachmentId)) { + scope.launch { startDownload(request) } + } + + // Автоочистка только терминальных состояний. + val terminalStatus = _downloads.value[attachmentId]?.status + if ( + terminalStatus == FileDownloadStatus.DONE || + terminalStatus == FileDownloadStatus.ERROR + ) { + scope.launch { + delay(5000) + val current = _downloads.value[attachmentId] + if ( + current?.status == FileDownloadStatus.DONE || + current?.status == FileDownloadStatus.ERROR + ) { + _downloads.update { it - attachmentId } + } + } + } + } + } + } + // ─── internal download logic (moved from FileAttachment) ─── private suspend fun downloadGroupFile( @@ -339,12 +461,19 @@ object FileDownloadManager { savedPath: String ) { _downloads.update { map -> + val previous = map[id] + val normalizedProgress = + when (status) { + FileDownloadStatus.DONE -> 1f + FileDownloadStatus.ERROR -> progress.coerceIn(0f, 1f) + else -> maxOf(progress, previous?.progress ?: 0f).coerceIn(0f, 1f) + } map + ( id to FileDownloadState( attachmentId = id, fileName = fileName, status = status, - progress = progress, + progress = normalizedProgress, accountPublicKey = accountPublicKey, savedPath = savedPath ) diff --git a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt index 0d31174..5d2d3d8 100644 --- a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt @@ -1,16 +1,19 @@ package com.rosetta.messenger.network import android.content.Context +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import okhttp3.* import okhttp3.MediaType.Companion.toMediaType import java.io.File import java.io.IOException +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -41,6 +44,7 @@ object TransportManager { private val _downloading = MutableStateFlow>(emptyList()) val downloading: StateFlow> = _downloading.asStateFlow() + private val activeDownloadCalls = ConcurrentHashMap() private val client = OkHttpClient.Builder() .connectTimeout(60, TimeUnit.SECONDS) @@ -93,6 +97,8 @@ object TransportManager { repeat(MAX_RETRIES) { attempt -> try { return block() + } catch (e: CancellationException) { + throw e } catch (e: Exception) { lastException = if (e is IOException) e else IOException("Download error: ${e.message}", e) if (attempt < MAX_RETRIES - 1) { @@ -110,6 +116,46 @@ object TransportManager { val packet = PacketRequestTransport() ProtocolManager.sendPacket(packet) } + + /** + * Принудительно отменяет активный HTTP call для скачивания attachment. + * Нужен для pause/resume в file bubble. + */ + fun cancelDownload(id: String) { + activeDownloadCalls.remove(id)?.cancel() + _downloading.value = _downloading.value.filter { it.id != id } + } + + private suspend fun awaitDownloadResponse(id: String, request: Request): Response = + suspendCancellableCoroutine { cont -> + val call = client.newCall(request) + activeDownloadCalls[id] = call + + cont.invokeOnCancellation { + activeDownloadCalls.remove(id, call) + call.cancel() + } + + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + activeDownloadCalls.remove(id, call) + if (call.isCanceled()) { + cont.cancel(CancellationException("Download cancelled")) + } else { + cont.resumeWithException(e) + } + } + + override fun onResponse(call: Call, response: Response) { + activeDownloadCalls.remove(id, call) + if (cont.isCancelled) { + response.close() + return + } + cont.resume(response) + } + }) + } /** * Загрузить файл на транспортный сервер с отслеживанием прогресса @@ -226,17 +272,7 @@ object TransportManager { .get() .build() - val response = suspendCoroutine { cont -> - client.newCall(request).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - cont.resumeWithException(e) - } - - override fun onResponse(call: Call, response: Response) { - cont.resume(response) - } - }) - } + val response = awaitDownloadResponse(id, request) if (!response.isSuccessful) { throw IOException("Download failed: ${response.code}") @@ -310,6 +346,7 @@ object TransportManager { ) throw e } finally { + activeDownloadCalls.remove(id)?.cancel() // Удаляем из списка скачиваний _downloading.value = _downloading.value.filter { it.id != id } } @@ -350,16 +387,7 @@ object TransportManager { .get() .build() - val response = suspendCoroutine { cont -> - client.newCall(request).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - cont.resumeWithException(e) - } - override fun onResponse(call: Call, response: Response) { - cont.resume(response) - } - }) - } + val response = awaitDownloadResponse(id, request) if (!response.isSuccessful) { throw IOException("Download failed: ${response.code}") @@ -416,6 +444,7 @@ object TransportManager { ) throw e } finally { + activeDownloadCalls.remove(id)?.cancel() _downloading.value = _downloading.value.filter { it.id != id } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index a14a170..967a1be 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -348,13 +348,17 @@ fun ChatDetailScreen( var simplePickerPreviewGalleryUris by remember { mutableStateOf>(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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index ed902c0..f3e773d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -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" diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertComponents.kt index d082f72..cce885b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertComponents.kt @@ -62,6 +62,7 @@ internal fun MediaGrid( mediaItems: List, selectedItemOrder: List, 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 { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertPhotoLayout.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertPhotoLayout.kt index d1a824c..02c5a28 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertPhotoLayout.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertPhotoLayout.kt @@ -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, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt index 3e084b0..67a03cb 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt @@ -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() diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index 8303514..50e9ec5 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -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 + ) + } } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 1a11220..ac38855 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -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 ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt index d2f81e3..1b2674b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt @@ -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, selectedItemOrder: List, 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