Compare commits

...

17 Commits

Author SHA1 Message Date
9afbbae5c9 v1.2.4: реальная пауза скачивания с resume по Range
All checks were successful
Android Kernel Build / build (push) Successful in 50m9s
2026-03-20 14:48:17 +05:00
4440016d5f v1.2.4: фиксы медиапикера, файловых загрузок и UI групп 2026-03-20 14:29:12 +05:00
0353f845a5 Фикс скелетона и залипания вкладок в профиле 2026-03-20 12:26:33 +05:00
004b54ec7c Релиз 1.2.4: фиксы чатов, медиа и release notes 2026-03-20 00:44:18 +05:00
5ecb2a8db4 Универсальные обои для всех разрешений 2026-03-19 23:35:28 +05:00
f34e520d03 Починил optimistic и сохранение групповых фото при отправке 2026-03-19 22:34:00 +05:00
1ba173be54 Довел pull-анимацию реквестов: моментальный показ первым элементом 2026-03-19 22:22:01 +05:00
d41674ff78 Сделал плавную вытягивающуюся анимацию реквестов в чат-листе 2026-03-19 20:00:02 +05:00
bd6e033ed3 Исправил скрытие реквестов в чат-листе как у архива Telegram 2026-03-19 19:53:07 +05:00
72a2cf1b70 Переделал механику реквестов: отдельный pull-gesture и ручка раскрытия 2026-03-19 19:41:59 +05:00
2cf64e80eb Сделал мгновенное раскрытие реквестов при pull вниз 2026-03-19 19:28:05 +05:00
2602084764 Починил повторное появление реквестов при прокрутке в чат-листе 2026-03-19 19:22:23 +05:00
420ea6e560 Убрал рывки анимации блока реквестов в списке чатов 2026-03-19 19:09:09 +05:00
53946e2e6e Сделал стабильное появление реквестов при оттягивании списка 2026-03-19 16:50:13 +05:00
4d4130fefd Исправил прыжки списка чатов при пустых запросах 2026-03-19 16:35:41 +05:00
09df7586e7 Разделил обои на наборы для темной и светлой темы 2026-03-19 16:28:18 +05:00
13b61cf720 Сделал скрытие клавиатуры на back-свайпе во всех экранах 2026-03-19 16:19:06 +05:00
18 changed files with 1606 additions and 703 deletions

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release // Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.2.3" val rosettaVersionName = "1.2.4"
val rosettaVersionCode = 25 // Increment on each release val rosettaVersionCode = 26 // Increment on each release
android { android {
namespace = "com.rosetta.messenger" namespace = "com.rosetta.messenger"

View File

@@ -19,14 +19,48 @@ object ReleaseNotes {
Что обновлено после версии 1.2.3 Что обновлено после версии 1.2.3
Группы и медиа Чат-лист и Requests
- Исправлено отображение групповых баблов и стеков сообщений - Полностью переработано поведение блока Requests: pull-жест, раскрытие и скрытие как у архива в Telegram
- Исправлено позиционирование аватарки: имя и аватар в группе теперь не разъезжаются - Доработана вытягивающаяся анимация: requests сразу появляются первым элементом при pull вниз
- Исправлена обрезка имени отправителя в медиа-баблах - Убраны рывки и прыжки списка чатов при анимациях и при пустом списке запросов
- Исправлено растяжение фото в forwarded/media-пузырях
Интерфейс Чаты и группы
- Убрана лишняя рамка вокруг аватарки в боковом меню - Исправлены групповые баблы и аватарки в стеках сообщений, устранены кривые состояния в медиа-блоках
- Исправлена обрезка имени отправителя в групповых медиа-сообщениях
- Плашки даты в диалоге приведены к Telegram-стилю, добавлена плавающая верхняя дата при скролле
- Сообщение «you joined the group» теперь белого цвета в тёмной теме и на обоях
Медиа и локальные данные
- Исправлена отправка нескольких фото: добавлен корректный optimistic UI и стабильное отображение до/после перезахода
- Экран редактирования фото после камеры унифицирован с редактором фото из галереи
- Удалённые сообщения теперь корректно удаляются локально и не возвращаются после открытия диалога
Обои и темы
- Разделены наборы обоев для светлой и тёмной темы
- Исправлено поведение обоев на разных разрешениях: убраны повторения/растяжения, фон отображается стабильнее
Навигация и UI
- Back-свайп теперь везде скрывает клавиатуру (как на экране поиска)
- На экране группы выровнены размеры иконок Encryption Key и Add Members
- Улучшен back-свайп на экране Encryption Key: возврат во внутреннюю страницу группы
- Приведён к нормальному размер индикатор ошибки в чат-листе
Медиапикер и камера
- Исправлено затемнение статус-бара при открытии медиапикера: больше не пропадает при активации камеры
- Переработано управление системными барами в attach picker и media picker для более естественного Telegram-поведения
- Камера в медиапикере теперь корректно блокируется во время закрытия, запись не стартует в момент dismiss
Файлы и загрузки
- Добавлена пауза и возобновление скачивания файлов прямо из пузырька (иконка pause/resume)
- Пауза скачивания теперь реальная: активный сетевой поток останавливается, а не только меняется UI-статус
- Resume продолжает загрузку с сохранённого места через HTTP Range (с безопасным fallback на полную перезагрузку, если сервер не поддерживает Range)
- Устранено дёргание прогресса при быстрых тапах по скачивающемуся файлу
- Прогресс скачивания стал стабильным и не откатывается назад после pause/resume
- Обновлён экран активных загрузок: добавлен статус Paused
Групповые сообщения
- Добавлен truncate для длинных имён отправителей в групповых пузырьках
- Убраны переносы в имени отправителя в шапке группового сообщения
""".trimIndent() """.trimIndent()
fun getNotice(version: String): String = fun getNotice(version: String): String =

View File

@@ -1,6 +1,5 @@
package com.rosetta.messenger.network package com.rosetta.messenger.network
import android.content.Context
import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto import com.rosetta.messenger.crypto.MessageCrypto
import kotlinx.coroutines.* import kotlinx.coroutines.*
@@ -20,6 +19,7 @@ data class FileDownloadState(
enum class FileDownloadStatus { enum class FileDownloadStatus {
QUEUED, QUEUED,
DOWNLOADING, DOWNLOADING,
PAUSED,
DECRYPTING, DECRYPTING,
DONE, DONE,
ERROR ERROR
@@ -35,6 +35,28 @@ object FileDownloadManager {
/** Текущие Job'ы — чтобы не запускать повторно */ /** Текущие Job'ы — чтобы не запускать повторно */
private val jobs = mutableMapOf<String, Job>() private val jobs = mutableMapOf<String, Job>()
/** Последние параметры скачивания — нужны для resume */
private val requests = mutableMapOf<String, DownloadRequest>()
/** Флаг, что cancel произошёл именно как user pause */
private val pauseRequested = mutableSetOf<String>()
/** Если пользователь очень быстро тапнул pause→resume, запускаем resume после фактической паузы */
private val resumeAfterPause = mutableSetOf<String>()
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
)
private fun encryptedPartFile(request: DownloadRequest): File {
val parent = request.savedFile.parentFile ?: request.savedFile.absoluteFile.parentFile
val safeId = request.attachmentId.take(32).replace(Regex("[^A-Za-z0-9._-]"), "_")
return File(parent, ".dl_${safeId}.part")
}
// ─── helpers ─── // ─── helpers ───
@@ -67,9 +89,16 @@ object FileDownloadManager {
*/ */
fun isDownloading(attachmentId: String): Boolean { fun isDownloading(attachmentId: String): Boolean {
val state = _downloads.value[attachmentId] ?: return false 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<FileDownloadState?> для конкретного attachment * Возвращает Flow<FileDownloadState?> для конкретного attachment
*/ */
@@ -81,7 +110,6 @@ object FileDownloadManager {
* Скачивание продолжается даже если пользователь вышел из чата. * Скачивание продолжается даже если пользователь вышел из чата.
*/ */
fun download( fun download(
context: Context,
attachmentId: String, attachmentId: String,
downloadTag: String, downloadTag: String,
chachaKey: String, chachaKey: String,
@@ -90,132 +118,234 @@ object FileDownloadManager {
fileName: String, fileName: String,
savedFile: File savedFile: File
) { ) {
// Уже в процессе? val request = DownloadRequest(
if (jobs[attachmentId]?.isActive == true) return attachmentId = attachmentId,
val normalizedAccount = accountPublicKey.trim() downloadTag = downloadTag,
val savedPath = savedFile.absolutePath 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 { pauseRequested.add(attachmentId)
try { val pausedProgress = current.progress.coerceIn(0f, 0.98f)
update( update(
attachmentId, id = attachmentId,
fileName, fileName = current.fileName,
FileDownloadStatus.DOWNLOADING, status = FileDownloadStatus.PAUSED,
0f, progress = pausedProgress,
normalizedAccount, accountPublicKey = current.accountPublicKey,
savedPath savedPath = current.savedPath
) )
// Запускаем polling прогресса из TransportManager TransportManager.cancelDownload(attachmentId)
val progressJob = launch { jobs[attachmentId]?.cancel()
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
)
}
}
}
val success = withContext(Dispatchers.IO) { fun resume(attachmentId: String) {
if (isGroupStoredKey(chachaKey)) { val request = requests[attachmentId] ?: return
downloadGroupFile( if (jobs[attachmentId]?.isActive == true) {
attachmentId = attachmentId, resumeAfterPause.add(attachmentId)
downloadTag = downloadTag, return
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 }
}
}
} }
pauseRequested.remove(attachmentId)
startDownload(request)
} }
/** /**
* Отменяет скачивание * Отменяет скачивание
*/ */
fun cancel(attachmentId: String) { fun cancel(attachmentId: String) {
requests[attachmentId]?.let { req ->
encryptedPartFile(req).delete()
}
pauseRequested.remove(attachmentId)
resumeAfterPause.remove(attachmentId)
requests.remove(attachmentId)
TransportManager.cancelDownload(attachmentId)
jobs[attachmentId]?.cancel() jobs[attachmentId]?.cancel()
jobs.remove(attachmentId) jobs.remove(attachmentId)
_downloads.update { it - 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 encryptedPart = encryptedPartFile(request)
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.
// Держим прогресс монотонным, чтобы он не дёргался вниз.
progressJob = launch {
TransportManager.downloading.collect { list ->
val entry = list.find { it.id == attachmentId } ?: return@collect
val rawCdn = (entry.progress / 100f) * 0.8f
val current = _downloads.value[attachmentId]?.progress ?: 0f
val stable = maxOf(current, rawCdn).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,
encryptedPartFile = encryptedPart,
accountPublicKey = request.accountPublicKey,
savedPath = savedPath
)
} else {
downloadDirectFile(
attachmentId = attachmentId,
downloadTag = request.downloadTag,
chachaKey = request.chachaKey,
privateKey = request.privateKey,
fileName = request.fileName,
savedFile = request.savedFile,
encryptedPartFile = encryptedPart,
accountPublicKey = request.accountPublicKey,
savedPath = savedPath
)
}
}
if (success) {
update(
attachmentId,
request.fileName,
FileDownloadStatus.DONE,
1f,
request.accountPublicKey,
savedPath
)
encryptedPart.delete()
requests.remove(attachmentId)
} 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) ─── // ─── internal download logic (moved from FileAttachment) ───
private suspend fun downloadGroupFile( private suspend fun downloadGroupFile(
@@ -225,10 +355,21 @@ object FileDownloadManager {
privateKey: String, privateKey: String,
fileName: String, fileName: String,
savedFile: File, savedFile: File,
encryptedPartFile: File,
accountPublicKey: String, accountPublicKey: String,
savedPath: String savedPath: String
): Boolean { ): Boolean {
val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag) val resumeBytes = if (encryptedPartFile.exists()) encryptedPartFile.length() else 0L
val encryptedFile =
TransportManager.downloadFileRawResumable(
id = attachmentId,
tag = downloadTag,
targetFile = encryptedPartFile,
resumeFromBytes = resumeBytes
)
val encryptedContent = withContext(Dispatchers.IO) {
encryptedFile.readText(Charsets.UTF_8)
}
update( update(
attachmentId, attachmentId,
fileName, fileName,
@@ -283,11 +424,18 @@ object FileDownloadManager {
privateKey: String, privateKey: String,
fileName: String, fileName: String,
savedFile: File, savedFile: File,
encryptedPartFile: File,
accountPublicKey: String, accountPublicKey: String,
savedPath: String savedPath: String
): Boolean { ): Boolean {
// Streaming: скачиваем во temp file val resumeBytes = if (encryptedPartFile.exists()) encryptedPartFile.length() else 0L
val tempFile = TransportManager.downloadFileRaw(attachmentId, downloadTag) val tempFile =
TransportManager.downloadFileRawResumable(
id = attachmentId,
tag = downloadTag,
targetFile = encryptedPartFile,
resumeFromBytes = resumeBytes
)
update( update(
attachmentId, attachmentId,
fileName, fileName,
@@ -316,7 +464,7 @@ object FileDownloadManager {
savedFile savedFile
) )
} finally { } finally {
tempFile.delete() encryptedPartFile.delete()
} }
} }
update( update(
@@ -339,12 +487,19 @@ object FileDownloadManager {
savedPath: String savedPath: String
) { ) {
_downloads.update { map -> _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 + ( map + (
id to FileDownloadState( id to FileDownloadState(
attachmentId = id, attachmentId = id,
fileName = fileName, fileName = fileName,
status = status, status = status,
progress = progress, progress = normalizedProgress,
accountPublicKey = accountPublicKey, accountPublicKey = accountPublicKey,
savedPath = savedPath savedPath = savedPath
) )

View File

@@ -1,20 +1,26 @@
package com.rosetta.messenger.network package com.rosetta.messenger.network
import android.content.Context import android.content.Context
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.* import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
import kotlin.coroutines.coroutineContext
/** /**
* Состояние загрузки/скачивания файла * Состояние загрузки/скачивания файла
@@ -41,6 +47,7 @@ object TransportManager {
private val _downloading = MutableStateFlow<List<TransportState>>(emptyList()) private val _downloading = MutableStateFlow<List<TransportState>>(emptyList())
val downloading: StateFlow<List<TransportState>> = _downloading.asStateFlow() val downloading: StateFlow<List<TransportState>> = _downloading.asStateFlow()
private val activeDownloadCalls = ConcurrentHashMap<String, Call>()
private val client = OkHttpClient.Builder() private val client = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS) .connectTimeout(60, TimeUnit.SECONDS)
@@ -93,6 +100,8 @@ object TransportManager {
repeat(MAX_RETRIES) { attempt -> repeat(MAX_RETRIES) { attempt ->
try { try {
return block() return block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
lastException = if (e is IOException) e else IOException("Download error: ${e.message}", e) lastException = if (e is IOException) e else IOException("Download error: ${e.message}", e)
if (attempt < MAX_RETRIES - 1) { if (attempt < MAX_RETRIES - 1) {
@@ -110,6 +119,54 @@ object TransportManager {
val packet = PacketRequestTransport() val packet = PacketRequestTransport()
ProtocolManager.sendPacket(packet) 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)
}
})
}
private fun parseContentRangeTotal(value: String?): Long? {
if (value.isNullOrBlank()) return null
// Example: "bytes 100-999/12345"
val totalPart = value.substringAfter('/').trim()
if (totalPart.isEmpty() || totalPart == "*") return null
return totalPart.toLongOrNull()
}
/** /**
* Загрузить файл на транспортный сервер с отслеживанием прогресса * Загрузить файл на транспортный сервер с отслеживанием прогресса
@@ -226,17 +283,7 @@ object TransportManager {
.get() .get()
.build() .build()
val response = suspendCoroutine<Response> { cont -> val response = awaitDownloadResponse(id, request)
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)
}
})
}
if (!response.isSuccessful) { if (!response.isSuccessful) {
throw IOException("Download failed: ${response.code}") throw IOException("Download failed: ${response.code}")
@@ -310,6 +357,7 @@ object TransportManager {
) )
throw e throw e
} finally { } finally {
activeDownloadCalls.remove(id)?.cancel()
// Удаляем из списка скачиваний // Удаляем из списка скачиваний
_downloading.value = _downloading.value.filter { it.id != id } _downloading.value = _downloading.value.filter { it.id != id }
} }
@@ -338,84 +386,129 @@ object TransportManager {
* @return Временный файл с зашифрованным содержимым * @return Временный файл с зашифрованным содержимым
*/ */
suspend fun downloadFileRaw(id: String, tag: String): File = withContext(Dispatchers.IO) { suspend fun downloadFileRaw(id: String, tag: String): File = withContext(Dispatchers.IO) {
val server = getActiveServer() val cacheDir = appContext?.cacheDir ?: throw IOException("No app context")
ProtocolManager.addLog("📥 Download raw start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server") val tempFile = File(cacheDir, "dlr_${id.take(16)}_${System.currentTimeMillis()}.tmp")
try {
downloadFileRawResumable(
id = id,
tag = tag,
targetFile = tempFile,
resumeFromBytes = 0L
)
} catch (e: Exception) {
tempFile.delete()
throw e
}
}
_downloading.value = _downloading.value + TransportState(id, 0) /**
* Resumable download with HTTP Range support.
* If server supports range (206), continues from `targetFile.length()`.
* If not, safely restarts from zero and rewrites target file.
*/
suspend fun downloadFileRawResumable(
id: String,
tag: String,
targetFile: File,
resumeFromBytes: Long = 0L
): File = withContext(Dispatchers.IO) {
val server = getActiveServer()
ProtocolManager.addLog(
"📥 Download raw(resume) start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server, resume=$resumeFromBytes"
)
_downloading.value = _downloading.value.filter { it.id != id } + TransportState(id, 0)
try { try {
withRetry { withRetry {
val request = Request.Builder() val existingBytes = if (targetFile.exists()) targetFile.length() else 0L
val startOffset = maxOf(existingBytes, resumeFromBytes.coerceAtLeast(0L))
.coerceAtMost(existingBytes)
val requestBuilder = Request.Builder()
.url("$server/d/$tag") .url("$server/d/$tag")
.get() .get()
.build() if (startOffset > 0L) {
requestBuilder.addHeader("Range", "bytes=$startOffset-")
val response = suspendCoroutine<Response> { 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, requestBuilder.build())
if (!response.isSuccessful) { if (!response.isSuccessful) {
throw IOException("Download failed: ${response.code}") throw IOException("Download failed: ${response.code}")
} }
val body = response.body ?: throw IOException("Empty response body") val body = response.body ?: throw IOException("Empty response body")
val contentLength = body.contentLength() val rangeAccepted = response.code == 206
val cacheDir = appContext?.cacheDir ?: throw IOException("No app context") val writeFromOffset = if (rangeAccepted) startOffset else 0L
val tempFile = File(cacheDir, "dlr_${id.take(16)}_${System.currentTimeMillis()}.tmp") val incomingLength = body.contentLength().coerceAtLeast(0L)
val totalFromHeader = parseContentRangeTotal(response.header("Content-Range"))
val totalBytes = when {
totalFromHeader != null && totalFromHeader > 0L -> totalFromHeader
incomingLength > 0L -> writeFromOffset + incomingLength
else -> -1L
}
try { if (writeFromOffset == 0L && targetFile.exists()) {
var totalRead = 0L targetFile.delete()
val buffer = ByteArray(64 * 1024) }
targetFile.parentFile?.mkdirs()
body.byteStream().use { inputStream -> val append = writeFromOffset > 0L
tempFile.outputStream().use { outputStream -> var totalRead = writeFromOffset
while (true) { val buffer = ByteArray(64 * 1024)
val bytesRead = inputStream.read(buffer)
if (bytesRead == -1) break body.byteStream().use { inputStream ->
outputStream.write(buffer, 0, bytesRead) java.io.FileOutputStream(targetFile, append).use { outputStream ->
totalRead += bytesRead while (true) {
if (contentLength > 0) { coroutineContext.ensureActive()
val progress = ((totalRead * 100) / contentLength).toInt().coerceIn(0, 99) val bytesRead = try {
_downloading.value = _downloading.value.map { inputStream.read(buffer)
if (it.id == id) it.copy(progress = progress) else it } catch (e: IOException) {
} if (!coroutineContext.isActive) {
throw CancellationException("Download cancelled", e)
}
throw e
}
if (bytesRead == -1) break
outputStream.write(buffer, 0, bytesRead)
totalRead += bytesRead
if (totalBytes > 0L) {
val progress =
((totalRead * 100L) / totalBytes).toInt().coerceIn(0, 99)
_downloading.value = _downloading.value.map {
if (it.id == id) it.copy(progress = progress) else it
} }
} }
} }
} }
if (contentLength > 0 && totalRead != contentLength) {
tempFile.delete()
throw IOException("Incomplete download: expected=$contentLength, got=$totalRead")
}
if (totalRead == 0L) {
tempFile.delete()
throw IOException("Empty download: 0 bytes received")
}
_downloading.value = _downloading.value.map {
if (it.id == id) it.copy(progress = 100) else it
}
ProtocolManager.addLog("✅ Download raw OK: id=${id.take(8)}, size=$totalRead")
tempFile
} catch (e: Exception) {
tempFile.delete()
throw e
} }
if (totalBytes > 0L && totalRead < totalBytes) {
throw IOException(
"Incomplete download: expected=$totalBytes, got=$totalRead"
)
}
if (totalRead == 0L) {
throw IOException("Empty download: 0 bytes received")
}
_downloading.value = _downloading.value.map {
if (it.id == id) it.copy(progress = 100) else it
}
ProtocolManager.addLog(
"✅ Download raw(resume) OK: id=${id.take(8)}, size=$totalRead"
)
targetFile
} }
} catch (e: Exception) { } catch (e: Exception) {
ProtocolManager.addLog( ProtocolManager.addLog(
"❌ Download raw failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}" "❌ Download raw(resume) failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
) )
throw e throw e
} finally { } finally {
activeDownloadCalls.remove(id)?.cancel()
_downloading.value = _downloading.value.filter { it.id != id } _downloading.value = _downloading.value.filter { it.id != id }
} }
} }

View File

@@ -2,13 +2,7 @@ package com.rosetta.messenger.ui.chats
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Shader
import android.graphics.drawable.BitmapDrawable
import android.net.Uri import android.net.Uri
import android.view.Gravity
import android.view.View
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -80,7 +74,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
@@ -101,7 +94,6 @@ import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert
import com.rosetta.messenger.ui.chats.components.* import com.rosetta.messenger.ui.chats.components.*
import com.rosetta.messenger.ui.chats.components.ImageEditorScreen
import com.rosetta.messenger.ui.chats.components.InAppCameraScreen import com.rosetta.messenger.ui.chats.components.InAppCameraScreen
import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen
import com.rosetta.messenger.ui.chats.input.* import com.rosetta.messenger.ui.chats.input.*
@@ -128,6 +120,7 @@ import kotlinx.coroutines.withContext
private val groupMembersCountCache = java.util.concurrent.ConcurrentHashMap<String, Int>() private val groupMembersCountCache = java.util.concurrent.ConcurrentHashMap<String, Int>()
private const val GROUP_MESSAGE_STACK_TIME_DIFF_MS = 5 * 60_000L private const val GROUP_MESSAGE_STACK_TIME_DIFF_MS = 5 * 60_000L
private const val DIRECT_MESSAGE_STACK_TIME_DIFF_MS = 60_000L private const val DIRECT_MESSAGE_STACK_TIME_DIFF_MS = 60_000L
private const val MESSAGE_SKELETON_VISIBILITY_DELAY_MS = 180L
private fun isSameLocalDay(firstTimestampMs: Long, secondTimestampMs: Long): Boolean { private fun isSameLocalDay(firstTimestampMs: Long, secondTimestampMs: Long): Boolean {
val firstCalendar = val firstCalendar =
@@ -241,6 +234,8 @@ fun ChatDetailScreen(
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
val dateHeaderTextColor = if (isDarkTheme || hasChatWallpaper) Color.White else secondaryTextColor val dateHeaderTextColor = if (isDarkTheme || hasChatWallpaper) Color.White else secondaryTextColor
val dateHeaderBackgroundColor =
if (isDarkTheme || hasChatWallpaper) Color(0x80212121) else Color(0xCCE8E8ED)
val headerIconColor = Color.White val headerIconColor = Color.White
// 🔥 Keyboard & Emoji Coordinator // 🔥 Keyboard & Emoji Coordinator
@@ -353,13 +348,17 @@ fun ChatDetailScreen(
var simplePickerPreviewGalleryUris by remember { mutableStateOf<List<Uri>>(emptyList()) } var simplePickerPreviewGalleryUris by remember { mutableStateOf<List<Uri>>(emptyList()) }
var simplePickerPreviewInitialIndex by remember { mutableIntStateOf(0) } var simplePickerPreviewInitialIndex by remember { mutableIntStateOf(0) }
// 🎨 Управление статус баром — ВСЕГДА чёрные иконки в светлой теме // 🎨 Управление статус баром.
// Важно: когда открыт media/camera overlay, статус-баром управляет сам overlay.
// Иначе ChatDetail может перетереть затемнение пикера в прозрачный.
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { SideEffect {
if (showImageViewer) { if (showImageViewer) {
SystemBarsStyleUtils.applyFullscreenDark(window, view) SystemBarsStyleUtils.applyFullscreenDark(window, view)
} else { } else {
if (window != null && view != null) { val isOverlayControllingSystemBars = showMediaPicker
if (!isOverlayControllingSystemBars && window != null && view != null) {
val ic = androidx.core.view.WindowCompat.getInsetsController(window, view) val ic = androidx.core.view.WindowCompat.getInsetsController(window, view)
window.statusBarColor = android.graphics.Color.TRANSPARENT window.statusBarColor = android.graphics.Color.TRANSPARENT
ic.isAppearanceLightStatusBars = false ic.isAppearanceLightStatusBars = false
@@ -398,6 +397,7 @@ fun ChatDetailScreen(
var pendingCameraPhotoUri by remember { var pendingCameraPhotoUri by remember {
mutableStateOf<Uri?>(null) mutableStateOf<Uri?>(null)
} // Фото для редактирования } // Фото для редактирования
var pendingCameraPhotoCaption by remember { mutableStateOf("") }
// 📷 Показать встроенную камеру (без системного превью) // 📷 Показать встроенную камеру (без системного превью)
var showInAppCamera by remember { mutableStateOf(false) } var showInAppCamera by remember { mutableStateOf(false) }
@@ -636,6 +636,15 @@ fun ChatDetailScreen(
// If typing, the user is obviously online — never show "offline" while typing // If typing, the user is obviously online — never show "offline" while typing
val isOnline = rawIsOnline || isTyping val isOnline = rawIsOnline || isTyping
val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона
val showMessageSkeleton by
produceState(initialValue = false, key1 = isLoading) {
if (!isLoading) {
value = false
return@produceState
}
delay(MESSAGE_SKELETON_VISIBILITY_DELAY_MS)
value = isLoading
}
// <20>🔥 Reply/Forward state // <20>🔥 Reply/Forward state
val replyMessages by viewModel.replyMessages.collectAsState() val replyMessages by viewModel.replyMessages.collectAsState()
@@ -1163,6 +1172,37 @@ fun ChatDetailScreen(
val showScrollToBottomButton by remember(messagesWithDates, isAtBottom, isSendingMessage) { val showScrollToBottomButton by remember(messagesWithDates, isAtBottom, isSendingMessage) {
derivedStateOf { messagesWithDates.isNotEmpty() && !isAtBottom && !isSendingMessage } derivedStateOf { messagesWithDates.isNotEmpty() && !isAtBottom && !isSendingMessage }
} }
val floatingDateText by remember(messagesWithDates, listState) {
derivedStateOf {
if (messagesWithDates.isEmpty()) {
return@derivedStateOf null
}
val layoutInfo = listState.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
if (visibleItems.isEmpty()) {
return@derivedStateOf null
}
val topVisibleItem =
visibleItems.minByOrNull { itemInfo ->
kotlin.math.abs(itemInfo.offset - layoutInfo.viewportStartOffset)
} ?: return@derivedStateOf null
val messageIndex = topVisibleItem.index
if (messageIndex !in messagesWithDates.indices) {
return@derivedStateOf null
}
getDateText(messagesWithDates[messageIndex].first.timestamp.time)
}
}
val showFloatingDateHeader by
remember(messagesWithDates, floatingDateText, isAtBottom, listState) {
derivedStateOf {
messagesWithDates.isNotEmpty() &&
floatingDateText != null &&
!isAtBottom &&
(listState.isScrollInProgress ||
listState.firstVisibleItemIndex > 0)
}
}
// Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации) // Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации)
// 🔥 Скроллим только если изменился ID самого нового сообщения // 🔥 Скроллим только если изменился ID самого нового сообщения
@@ -2335,10 +2375,9 @@ fun ChatDetailScreen(
// Keep wallpaper on a fixed full-screen layer so it doesn't rescale // Keep wallpaper on a fixed full-screen layer so it doesn't rescale
// when content paddings (bottom bar/IME) change. // when content paddings (bottom bar/IME) change.
if (chatWallpaperResId != null) { if (chatWallpaperResId != null) {
TiledChatWallpaper( ChatWallpaperBackground(
wallpaperResId = chatWallpaperResId, wallpaperResId = chatWallpaperResId,
modifier = Modifier.matchParentSize(), modifier = Modifier.matchParentSize()
tileScale = 0.9f
) )
} else { } else {
Box( Box(
@@ -2365,7 +2404,7 @@ fun ChatDetailScreen(
when { when {
// 🔥 СКЕЛЕТОН - показываем пока загружаются // 🔥 СКЕЛЕТОН - показываем пока загружаются
// сообщения // сообщения
isLoading -> { showMessageSkeleton -> {
MessageSkeletonList( MessageSkeletonList(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
isGroupChat = isGroupChat, isGroupChat = isGroupChat,
@@ -2373,6 +2412,9 @@ fun ChatDetailScreen(
Modifier.fillMaxSize() Modifier.fillMaxSize()
) )
} }
isLoading && messages.isEmpty() -> {
Box(modifier = Modifier.fillMaxSize())
}
// Пустое состояние (нет сообщений) // Пустое состояние (нет сообщений)
messages.isEmpty() -> { messages.isEmpty() -> {
Column( Column(
@@ -2563,12 +2605,41 @@ fun ChatDetailScreen(
isMessageBoundary(message, prevMessage) isMessageBoundary(message, prevMessage)
val isGroupStart = val isGroupStart =
isMessageBoundary(message, nextMessage) isMessageBoundary(message, nextMessage)
val runHeadIndex =
messageRunNewestIndex.getOrNull(
index
) ?: index
val runTailIndex =
messageRunOldestIndexByHead
.getOrNull(
runHeadIndex
)
?: runHeadIndex
val isHeadPhase =
incomingRunAvatarUiState
.showOnRunHeads
.contains(
runHeadIndex
)
val isTailPhase =
incomingRunAvatarUiState
.showOnRunTails
.contains(
runHeadIndex
)
val showIncomingGroupAvatar = val showIncomingGroupAvatar =
isGroupChat && isGroupChat &&
!message.isOutgoing && !message.isOutgoing &&
senderPublicKeyForMessage senderPublicKeyForMessage
.isNotBlank() && .isNotBlank() &&
isGroupStart ((index ==
runHeadIndex &&
isHeadPhase &&
showTail) ||
(index ==
runTailIndex &&
isTailPhase &&
isGroupStart))
Column { Column {
if (showDate if (showDate
@@ -2579,8 +2650,10 @@ fun ChatDetailScreen(
message.timestamp message.timestamp
.time .time
), ),
secondaryTextColor = textColor =
dateHeaderTextColor dateHeaderTextColor,
backgroundColor =
dateHeaderBackgroundColor
) )
} }
val selectionKey = val selectionKey =
@@ -2951,6 +3024,42 @@ fun ChatDetailScreen(
} }
} }
} }
androidx.compose.animation.AnimatedVisibility(
visible =
showFloatingDateHeader &&
!isLoading &&
!isSelectionMode,
enter =
fadeIn(animationSpec = tween(120)) +
slideInVertically(
animationSpec =
tween(120)
) { height ->
-height / 2
},
exit =
fadeOut(animationSpec = tween(100)) +
slideOutVertically(
animationSpec =
tween(100)
) { height ->
-height / 2
},
modifier =
Modifier.align(Alignment.TopCenter)
.padding(top = 8.dp)
.zIndex(3f)
) {
floatingDateText?.let { dateText ->
DateHeader(
dateText = dateText,
textColor = dateHeaderTextColor,
backgroundColor =
dateHeaderBackgroundColor,
verticalPadding = 0.dp
)
}
}
if (incomingRunAvatarUiState.overlays.isNotEmpty()) { if (incomingRunAvatarUiState.overlays.isNotEmpty()) {
val avatarInsetPx = val avatarInsetPx =
with(density) { with(density) {
@@ -3568,7 +3677,8 @@ fun ChatDetailScreen(
InAppCameraScreen( InAppCameraScreen(
onDismiss = { showInAppCamera = false }, onDismiss = { showInAppCamera = false },
onPhotoTaken = { photoUri -> onPhotoTaken = { photoUri ->
// Сначала редактор (skipEnterAnimation=1f), потом убираем камеру // После камеры открываем тот же fullscreen-редактор,
// что и для фото из галереи.
pendingCameraPhotoUri = photoUri pendingCameraPhotoUri = photoUri
showInAppCamera = false showInAppCamera = false
} }
@@ -3577,26 +3687,25 @@ fun ChatDetailScreen(
// 📷 Image Editor для фото с камеры (с caption как в Telegram) // 📷 Image Editor для фото с камеры (с caption как в Telegram)
pendingCameraPhotoUri?.let { uri -> pendingCameraPhotoUri?.let { uri ->
ImageEditorScreen( SimpleFullscreenPhotoOverlay(
imageUri = uri, imageUri = uri,
onDismiss = { modifier = Modifier.fillMaxSize().zIndex(100f),
pendingCameraPhotoUri = null showCaptionInput = true,
inputFocusTrigger++ caption = pendingCameraPhotoCaption,
}, onCaptionChange = { pendingCameraPhotoCaption = it },
onSave = { editedUri -> isDarkTheme = isDarkTheme,
// 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ onSend = { editedUri, caption ->
viewModel.sendImageFromUri(editedUri, "")
showMediaPicker = false
},
onSaveWithCaption = { editedUri, caption ->
// 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ
viewModel.sendImageFromUri(editedUri, caption) viewModel.sendImageFromUri(editedUri, caption)
showMediaPicker = false showMediaPicker = false
pendingCameraPhotoUri = null
pendingCameraPhotoCaption = ""
inputFocusTrigger++
}, },
isDarkTheme = isDarkTheme, onDismiss = {
showCaptionInput = true, pendingCameraPhotoUri = null
recipientName = user.title, pendingCameraPhotoCaption = ""
skipEnterAnimation = true // Из камеры — мгновенно, без fade inputFocusTrigger++
}
) )
} }
@@ -3676,60 +3785,14 @@ private fun GroupMembersSubtitleSkeleton() {
} }
@Composable @Composable
private fun TiledChatWallpaper( private fun ChatWallpaperBackground(
wallpaperResId: Int, wallpaperResId: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier
tileScale: Float = 0.9f
) { ) {
val context = LocalContext.current Image(
val wallpaperDrawable = painter = painterResource(id = wallpaperResId),
remember(wallpaperResId, tileScale, context) { contentDescription = "Chat wallpaper",
val decoded = BitmapFactory.decodeResource(context.resources, wallpaperResId)
val normalizedScale = tileScale.coerceIn(0.2f, 2f)
val scaledBitmap =
decoded?.let { original ->
if (normalizedScale == 1f) {
original
} else {
val width =
(original.width * normalizedScale)
.toInt()
.coerceAtLeast(1)
val height =
(original.height * normalizedScale)
.toInt()
.coerceAtLeast(1)
val scaled =
Bitmap.createScaledBitmap(
original,
width,
height,
true
)
if (scaled != original) {
original.recycle()
}
scaled
}
}
val safeBitmap =
scaledBitmap
?: Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
.apply {
eraseColor(android.graphics.Color.TRANSPARENT)
}
BitmapDrawable(context.resources, safeBitmap).apply {
setTileModeXY(Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
gravity = Gravity.TOP or Gravity.START
}
}
AndroidView(
modifier = modifier, modifier = modifier,
factory = { ctx -> View(ctx).apply { background = wallpaperDrawable } }, contentScale = ContentScale.Crop
update = { view -> view.background = wallpaperDrawable }
) )
} }

View File

@@ -2169,15 +2169,27 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/** 🔥 Удалить сообщение (для ошибки отправки) */ /** 🔥 Удалить сообщение (для ошибки отправки) */
fun deleteMessage(messageId: String) { fun deleteMessage(messageId: String) {
val account = myPublicKey ?: return
val opponent = opponentKey ?: return
val dialogKey = getDialogKey(account, opponent)
// Удаляем из UI сразу на main // Удаляем из UI сразу на main
_messages.value = _messages.value.filter { it.id != messageId } val updatedMessages = _messages.value.filter { it.id != messageId }
_messages.value = updatedMessages
// Синхронизируем глобальный кэш диалога, иначе удалённые сообщения могут вернуться
// при повторном открытии чата из stale cache.
updateCacheWithLimit(account, dialogKey, updatedMessages)
messageRepository.clearDialogCache(opponent)
// Удаляем из БД в IO + удаляем pin если был // Удаляем из БД в IO + удаляем pin если был
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val account = myPublicKey ?: return@launch
val dialogKey = opponentKey ?: return@launch
pinnedMessageDao.removePin(account, dialogKey, messageId) pinnedMessageDao.removePin(account, dialogKey, messageId)
messageDao.deleteMessage(account, messageId) messageDao.deleteMessage(account, messageId)
if (account == opponent) {
dialogDao.updateSavedMessagesDialogFromMessages(account)
} else {
dialogDao.updateDialogFromMessages(account, opponent)
}
} }
} }
@@ -3500,15 +3512,101 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
return return
} }
val recipient = opponentKey
val sender = myPublicKey
val privateKey = myPrivateKey
val context = getApplication<Application>() val context = getApplication<Application>()
val groupDebugId = UUID.randomUUID().toString().replace("-", "").take(8) val groupDebugId = UUID.randomUUID().toString().replace("-", "").take(8)
if (recipient == null || sender == null || privateKey == null) {
ProtocolManager.addLog(
"❌ IMG-GROUP $groupDebugId | aborted: missing keys (recipient=${recipient != null}, sender=${sender != null}, private=${privateKey != null})"
)
return
}
if (isSending) {
ProtocolManager.addLog("⚠️ IMG-GROUP $groupDebugId | skipped: another send in progress")
return
}
isSending = true
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val text = caption.trim()
val attachmentIds = imageUris.indices.map { index -> "img_${timestamp}_$index" }
val optimisticAttachments =
imageUris.mapIndexed { index, uri ->
MessageAttachment(
id = attachmentIds[index],
blob = "",
type = AttachmentType.IMAGE,
preview = "",
width = 0,
height = 0,
localUri = uri.toString()
)
}
addMessageSafely(
ChatMessage(
id = messageId,
text = text,
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING,
attachments = optimisticAttachments
)
)
_inputText.value = ""
ProtocolManager.addLog( ProtocolManager.addLog(
"📸 IMG-GROUP $groupDebugId | prepare start: count=${imageUris.size}, captionLen=${caption.trim().length}" "📸 IMG-GROUP $groupDebugId | prepare start: count=${imageUris.size}, captionLen=${caption.trim().length}"
) )
backgroundUploadScope.launch { backgroundUploadScope.launch {
try {
val optimisticAttachmentsJson =
JSONArray().apply {
imageUris.forEachIndexed { index, uri ->
put(
JSONObject().apply {
put("id", attachmentIds[index])
put("type", AttachmentType.IMAGE.value)
put("preview", "")
put("blob", "")
put("width", 0)
put("height", 0)
put("localUri", uri.toString())
}
)
}
}.toString()
saveMessageToDatabase(
messageId = messageId,
text = text,
encryptedContent = "",
encryptedKey = "",
timestamp = timestamp,
isFromMe = true,
delivered = 0,
attachmentsJson = optimisticAttachmentsJson,
opponentPublicKey = recipient
)
saveDialog(
lastMessage = if (text.isNotEmpty()) text else "📷 ${imageUris.size} photos",
timestamp = timestamp,
opponentPublicKey = recipient
)
} catch (_: Exception) {
ProtocolManager.addLog("⚠️ IMG-GROUP $groupDebugId | optimistic DB save skipped (non-fatal)")
}
val preparedImages = val preparedImages =
imageUris.mapIndexedNotNull { index, uri -> imageUris.mapIndexed { index, uri ->
val (width, height) = val (width, height) =
com.rosetta.messenger.utils.MediaUtils.getImageDimensions( com.rosetta.messenger.utils.MediaUtils.getImageDimensions(
context, context,
@@ -3523,7 +3621,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
ProtocolManager.addLog( ProtocolManager.addLog(
"❌ IMG-GROUP $groupDebugId | item#$index base64 conversion failed" "❌ IMG-GROUP $groupDebugId | item#$index base64 conversion failed"
) )
return@mapIndexedNotNull null throw IllegalStateException(
"group item#$index base64 conversion failed"
)
} }
val blurhash = val blurhash =
com.rosetta.messenger.utils.MediaUtils.generateBlurhash( com.rosetta.messenger.utils.MediaUtils.generateBlurhash(
@@ -3533,26 +3633,156 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
ProtocolManager.addLog( ProtocolManager.addLog(
"📸 IMG-GROUP $groupDebugId | item#$index prepared: ${width}x$height, base64Len=${imageBase64.length}, blurhashLen=${blurhash.length}" "📸 IMG-GROUP $groupDebugId | item#$index prepared: ${width}x$height, base64Len=${imageBase64.length}, blurhashLen=${blurhash.length}"
) )
ImageData( index to
base64 = imageBase64, ImageData(
blurhash = blurhash, base64 = imageBase64,
width = width, blurhash = blurhash,
height = height width = width,
) height = height
)
} }
if (preparedImages.isEmpty()) { if (preparedImages.isEmpty()) {
ProtocolManager.addLog( ProtocolManager.addLog(
"❌ IMG-GROUP $groupDebugId | no prepared images, send canceled" "❌ IMG-GROUP $groupDebugId | no prepared images, send canceled"
) )
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
isSending = false
return@launch return@launch
} }
ProtocolManager.addLog( ProtocolManager.addLog(
"📸 IMG-GROUP $groupDebugId | prepare done: ready=${preparedImages.size}" "📸 IMG-GROUP $groupDebugId | prepare done: ready=${preparedImages.size}"
) )
withContext(Dispatchers.Main) { try {
sendImageGroup(preparedImages, caption) val groupStartedAt = System.currentTimeMillis()
val encryptionContext =
buildEncryptionContext(
plaintext = text,
recipient = recipient,
privateKey = privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val isSavedMessages = sender == recipient
val networkAttachments = mutableListOf<MessageAttachment>()
val finalDbAttachments = JSONArray()
val finalAttachmentsById = mutableMapOf<String, MessageAttachment>()
for ((originalIndex, imageData) in preparedImages) {
val attachmentId = attachmentIds[originalIndex]
val encryptedImageBlob =
encryptAttachmentPayload(imageData.base64, encryptionContext)
val uploadTag =
if (!isSavedMessages) {
TransportManager.uploadFile(attachmentId, encryptedImageBlob)
} else {
""
}
val previewWithTag =
if (uploadTag.isNotEmpty()) "$uploadTag::${imageData.blurhash}"
else imageData.blurhash
AttachmentFileManager.saveAttachment(
context = context,
blob = imageData.base64,
attachmentId = attachmentId,
publicKey = sender,
privateKey = privateKey
)
val finalAttachment =
MessageAttachment(
id = attachmentId,
blob = if (uploadTag.isNotEmpty()) "" else encryptedImageBlob,
type = AttachmentType.IMAGE,
preview = previewWithTag,
width = imageData.width,
height = imageData.height,
localUri = ""
)
networkAttachments.add(finalAttachment)
finalAttachmentsById[attachmentId] = finalAttachment
finalDbAttachments.put(
JSONObject().apply {
put("id", attachmentId)
put("type", AttachmentType.IMAGE.value)
put("preview", previewWithTag)
put("blob", "")
put("width", imageData.width)
put("height", imageData.height)
}
)
}
val packet =
PacketMessage().apply {
fromPublicKey = sender
toPublicKey = recipient
content = encryptedContent
chachaKey = encryptedKey
this.aesChachaKey = aesChachaKey
this.timestamp = timestamp
this.privateKey = privateKeyHash
this.messageId = messageId
attachments = networkAttachments
}
if (!isSavedMessages) {
ProtocolManager.send(packet)
}
updateMessageStatusAndAttachmentsInDb(
messageId = messageId,
delivered = 1,
attachmentsJson = finalDbAttachments.toString()
)
withContext(Dispatchers.Main) {
_messages.value =
_messages.value.map { msg ->
if (msg.id != messageId) return@map msg
msg.copy(
status = MessageStatus.SENT,
attachments =
msg.attachments.map { current ->
val final = finalAttachmentsById[current.id]
if (final != null) {
current.copy(
preview = final.preview,
width = final.width,
height = final.height,
localUri = ""
)
} else {
current.copy(localUri = "")
}
}
)
}
updateCacheFromCurrentMessages()
}
saveDialog(
lastMessage = if (text.isNotEmpty()) text else "📷 ${imageUris.size} photos",
timestamp = timestamp,
opponentPublicKey = recipient
)
logPhotoPipeline(
messageId,
"group-from-uri completed; totalElapsed=${System.currentTimeMillis() - groupStartedAt}ms"
)
} catch (e: Exception) {
logPhotoPipelineError(messageId, "group-from-uri", e)
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
} finally {
isSending = false
} }
} }
} }

View File

@@ -85,6 +85,7 @@ import compose.icons.tablericons.*
import com.rosetta.messenger.ui.icons.TelegramIcons import com.rosetta.messenger.ui.icons.TelegramIcons
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlin.math.hypot import kotlin.math.hypot
@@ -108,6 +109,8 @@ private enum class DeviceResolveAction {
DECLINE DECLINE
} }
private const val SKELETON_VISIBILITY_DELAY_MS = 180L
// Avatar colors matching React Native app (Mantine inspired) // Avatar colors matching React Native app (Mantine inspired)
// Light theme colors (background lighter, text darker) // Light theme colors (background lighter, text darker)
private val avatarColorsLight = private val avatarColorsLight =
@@ -480,6 +483,9 @@ fun ChatsListScreen(
it.status == it.status ==
com.rosetta.messenger.network.FileDownloadStatus com.rosetta.messenger.network.FileDownloadStatus
.DOWNLOADING || .DOWNLOADING ||
it.status ==
com.rosetta.messenger.network.FileDownloadStatus
.PAUSED ||
it.status == it.status ==
com.rosetta.messenger.network.FileDownloadStatus com.rosetta.messenger.network.FileDownloadStatus
.DECRYPTING .DECRYPTING
@@ -1791,7 +1797,29 @@ fun ChatsListScreen(
val requests = chatsState.requests val requests = chatsState.requests
val requestsCount = chatsState.requestsCount val requestsCount = chatsState.requestsCount
val showSkeleton = isLoading val showSkeleton by
produceState(
initialValue = false,
key1 = isLoading
) {
if (!isLoading) {
value = false
return@produceState
}
delay(SKELETON_VISIBILITY_DELAY_MS)
value = isLoading
}
val chatListState = rememberLazyListState()
var isRequestsVisible by remember { mutableStateOf(true) }
var requestsPullProgress by remember { mutableStateOf(0f) }
var lastAutoScrolledVerificationId by remember {
mutableStateOf<String?>(null)
}
val localDensity = LocalDensity.current
val requestsRevealThresholdPx =
remember(localDensity) { with(localDensity) { 28.dp.toPx() } }
val requestsHideThresholdPx =
remember(localDensity) { with(localDensity) { 16.dp.toPx() } }
AnimatedContent( AnimatedContent(
targetState = showDownloadsScreen, targetState = showDownloadsScreen,
@@ -1999,6 +2027,8 @@ fun ChatsListScreen(
} // Close Box wrapper } // Close Box wrapper
} else if (showSkeleton) { } else if (showSkeleton) {
ChatsListSkeleton(isDarkTheme = isDarkTheme) ChatsListSkeleton(isDarkTheme = isDarkTheme)
} else if (isLoading && chatsState.isEmpty) {
Box(modifier = Modifier.fillMaxSize())
} else if (chatsState.isEmpty) { } else if (chatsState.isEmpty) {
EmptyChatsState( EmptyChatsState(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
@@ -2056,14 +2086,6 @@ fun ChatsListScreen(
} }
} }
// Track scroll direction to hide/show Requests
val chatListState = rememberLazyListState()
var isRequestsVisible by remember { mutableStateOf(true) }
var lastAutoScrolledVerificationId by remember {
mutableStateOf<String?>(null)
}
val hapticFeedback = LocalHapticFeedback.current
// When a new device confirmation banner appears at the top, // When a new device confirmation banner appears at the top,
// smoothly bring the list to top so the banner is visible. // smoothly bring the list to top so the banner is visible.
LaunchedEffect(pendingDeviceVerification?.deviceId) { LaunchedEffect(pendingDeviceVerification?.deviceId) {
@@ -2087,44 +2109,135 @@ fun ChatsListScreen(
lastAutoScrolledVerificationId = verificationId lastAutoScrolledVerificationId = verificationId
} }
// NestedScroll — ловим направление свайпа даже без скролла val requestsNestedScroll =
// Для появления: накапливаем pull down дельту, нужен сильный жест remember(
val requestsNestedScroll = remember(hapticFeedback) { requestsCount,
var accumulatedPullDown = 0f chatListState,
object : androidx.compose.ui.input.nestedscroll.NestedScrollConnection { requestsRevealThresholdPx,
override fun onPreScroll( requestsHideThresholdPx,
available: androidx.compose.ui.geometry.Offset, hapticFeedback
source: androidx.compose.ui.input.nestedscroll.NestedScrollSource ) {
): androidx.compose.ui.geometry.Offset { var accumulatedPullDown = 0f
if (available.y < -10f) { var accumulatedPullUp = 0f
// Свайп вверх — прячем легко val pullDownLimit =
accumulatedPullDown = 0f requestsRevealThresholdPx * 1.25f
isRequestsVisible = false object : androidx.compose.ui.input.nestedscroll.NestedScrollConnection {
} else if (available.y > 0f && !isRequestsVisible) { override fun onPreScroll(
// Свайп вниз — накапливаем для появления available: androidx.compose.ui.geometry.Offset,
accumulatedPullDown += available.y source: androidx.compose.ui.input.nestedscroll.NestedScrollSource
if (accumulatedPullDown > 120f) { ): androidx.compose.ui.geometry.Offset {
isRequestsVisible = true if (source != androidx.compose.ui.input.nestedscroll.NestedScrollSource.Drag ||
requestsCount <= 0
) {
accumulatedPullDown = 0f accumulatedPullDown = 0f
hapticFeedback.performHapticFeedback( accumulatedPullUp = 0f
HapticFeedbackType.LongPress requestsPullProgress = 0f
return androidx.compose.ui.geometry.Offset.Zero
}
val atTop = !chatListState.canScrollBackward
val nearTop =
chatListState.firstVisibleItemIndex == 0 &&
chatListState.firstVisibleItemScrollOffset <=
2
if (available.y < 0f &&
isRequestsVisible &&
(atTop ||
nearTop)
) {
accumulatedPullUp += -available.y
accumulatedPullDown = 0f
requestsPullProgress = 0f
if (accumulatedPullUp >= requestsHideThresholdPx) {
isRequestsVisible = false
accumulatedPullUp = 0f
}
return androidx.compose.ui.geometry.Offset(
0f,
available.y
) )
} }
} else if (available.y <= 0f) { if (available.y >= 0f) {
accumulatedPullDown = 0f accumulatedPullUp = 0f
} }
return androidx.compose.ui.geometry.Offset.Zero
}
override suspend fun onPostFling( if (!isRequestsVisible && atTop) {
consumed: androidx.compose.ui.unit.Velocity, if (available.y > 0f) {
available: androidx.compose.ui.unit.Velocity accumulatedPullDown =
): androidx.compose.ui.unit.Velocity { (accumulatedPullDown + available.y)
accumulatedPullDown = 0f .coerceAtMost(
return androidx.compose.ui.unit.Velocity.Zero pullDownLimit
)
requestsPullProgress =
(accumulatedPullDown / requestsRevealThresholdPx)
.coerceIn(
0f,
1.15f
)
if (accumulatedPullDown >= requestsRevealThresholdPx) {
isRequestsVisible = true
accumulatedPullDown = 0f
accumulatedPullUp = 0f
requestsPullProgress = 0f
hapticFeedback.performHapticFeedback(
HapticFeedbackType.LongPress
)
}
return androidx.compose.ui.geometry.Offset(
0f,
available.y
)
}
if (available.y < 0f &&
accumulatedPullDown > 0f
) {
accumulatedPullDown =
(accumulatedPullDown + available.y)
.coerceAtLeast(
0f
)
requestsPullProgress =
(accumulatedPullDown / requestsRevealThresholdPx)
.coerceIn(
0f,
1.15f
)
return androidx.compose.ui.geometry.Offset(
0f,
available.y
)
}
} else if (!isRequestsVisible && !atTop) {
accumulatedPullDown = 0f
requestsPullProgress = 0f
}
return androidx.compose.ui.geometry.Offset.Zero
}
override suspend fun onPreFling(
available: androidx.compose.ui.unit.Velocity
): androidx.compose.ui.unit.Velocity {
if (!isRequestsVisible) {
accumulatedPullDown = 0f
requestsPullProgress = 0f
}
return androidx.compose.ui.unit.Velocity.Zero
}
override suspend fun onPostFling(
consumed: androidx.compose.ui.unit.Velocity,
available: androidx.compose.ui.unit.Velocity
): androidx.compose.ui.unit.Velocity {
accumulatedPullDown = 0f
accumulatedPullUp = 0f
requestsPullProgress = 0f
return androidx.compose.ui.unit.Velocity.Zero
}
} }
} }
}
LazyColumn( LazyColumn(
state = chatListState, state = chatListState,
@@ -2163,102 +2276,96 @@ fun ChatsListScreen(
} }
} }
item(key = "requests_section") { if (requestsCount > 0) {
val isRequestsSectionVisible = item(key = "requests_section") {
requestsCount > 0 && val requestsSectionProgress by
isRequestsVisible animateFloatAsState(
AnimatedVisibility( targetValue =
visible = if (isRequestsVisible) 1f
isRequestsSectionVisible, else requestsPullProgress,
enter =
slideInVertically(
initialOffsetY = {
fullHeight ->
-fullHeight /
3
},
animationSpec = animationSpec =
tween( spring(
durationMillis = dampingRatio =
260, Spring.DampingRatioNoBouncy,
easing = stiffness =
FastOutSlowInEasing Spring.StiffnessMediumLow
) ),
) + label =
expandVertically( "requestsSectionProgress"
expandFrom = )
Alignment val clampedProgress =
.Top, requestsSectionProgress
animationSpec = .coerceIn(
tween( 0f,
durationMillis = 1.15f
260, )
easing = val revealProgress =
FastOutSlowInEasing FastOutSlowInEasing
) .transform(
) + clampedProgress
fadeIn( .coerceIn(
animationSpec = 0f,
tween( 1f
durationMillis =
180
),
initialAlpha =
0.7f
),
exit =
slideOutVertically(
targetOffsetY = {
fullHeight ->
-fullHeight /
3
},
animationSpec =
tween(
durationMillis =
220,
easing =
FastOutSlowInEasing
)
) +
shrinkVertically(
shrinkTowards =
Alignment
.Top,
animationSpec =
tween(
durationMillis =
220,
easing =
FastOutSlowInEasing
)
) +
fadeOut(
animationSpec =
tween(
durationMillis =
140
) )
) )
) { val stretchOvershoot =
Column { (clampedProgress - 1f)
RequestsSection( .coerceAtLeast(
count = 0f
requestsCount, )
requests = val sectionHeight =
requests, 76.dp *
isDarkTheme = revealProgress +
isDarkTheme, 10.dp *
onClick = { stretchOvershoot
openRequestsRouteSafely() val sectionAlpha =
(0.55f + revealProgress * 0.45f)
.coerceIn(
0f,
1f
)
if (isRequestsVisible ||
sectionHeight > 0.5.dp
) {
Column {
Box(
modifier =
Modifier.fillMaxWidth()
.height(
sectionHeight
)
.clipToBounds()
.graphicsLayer {
alpha =
if (isRequestsVisible)
1f
else sectionAlpha
}
) {
RequestsSection(
count =
requestsCount,
requests =
requests,
isDarkTheme =
isDarkTheme,
onClick = {
isRequestsVisible =
true
requestsPullProgress =
0f
openRequestsRouteSafely()
}
)
} }
) Divider(
Divider( color =
color = dividerColor,
dividerColor, thickness =
thickness = 0.5.dp
0.5.dp )
) }
} }
} }
} }
@@ -2332,16 +2439,7 @@ fun ChatsListScreen(
listBackgroundColor listBackgroundColor
} }
Column( Column {
modifier =
Modifier.animateItemPlacement(
animationSpec =
tween(
durationMillis = 200,
easing = FastOutSlowInEasing
)
)
) {
SwipeableDialogItem( SwipeableDialogItem(
dialog = dialog =
dialog, dialog,
@@ -4383,17 +4481,17 @@ fun DialogItemContent(
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Box( Box(
modifier = modifier =
Modifier.size(22.dp) Modifier.size(16.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color(0xFFE53935)), .background(Color(0xFFE53935)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = "!", text = "!",
fontSize = 13.sp, fontSize = 10.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color.White, color = Color.White,
lineHeight = 13.sp, lineHeight = 10.sp,
maxLines = 1 maxLines = 1
) )
} }
@@ -4962,6 +5060,7 @@ private fun formatDownloadStatusText(
return when (item.status) { return when (item.status) {
com.rosetta.messenger.network.FileDownloadStatus.QUEUED -> "Queued" com.rosetta.messenger.network.FileDownloadStatus.QUEUED -> "Queued"
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> "Downloading $percent%" 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.DECRYPTING -> "Decrypting"
com.rosetta.messenger.network.FileDownloadStatus.DONE -> "Completed" com.rosetta.messenger.network.FileDownloadStatus.DONE -> "Completed"
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> "Download failed" com.rosetta.messenger.network.FileDownloadStatus.ERROR -> "Download failed"

View File

@@ -137,6 +137,7 @@ import com.rosetta.messenger.ui.chats.components.ViewableImage
import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.SharedMediaFastScrollOverlay import com.rosetta.messenger.ui.components.SharedMediaFastScrollOverlay
import com.rosetta.messenger.ui.components.SwipeBackContainer
import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.icons.TelegramIcons import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.ui.settings.FullScreenAvatarViewer import com.rosetta.messenger.ui.settings.FullScreenAvatarViewer
@@ -339,6 +340,7 @@ fun GroupInfoScreen(
val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6) val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
val actionContentColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E) val actionContentColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E)
val groupActionButtonBlue = if (isDarkTheme) Color(0xFF4A4A4D) else Color(0xFF2478C2) val groupActionButtonBlue = if (isDarkTheme) Color(0xFF4A4A4D) else Color(0xFF2478C2)
val groupMenuTrailingIconSize = 22.dp
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
@@ -805,8 +807,8 @@ fun GroupInfoScreen(
swipedMemberKey = null swipedMemberKey = null
} }
} }
LaunchedEffect(swipedMemberKey) { LaunchedEffect(swipedMemberKey, showEncryptionPage) {
onSwipeBackEnabledChanged(swipedMemberKey == null) onSwipeBackEnabledChanged(swipedMemberKey == null && !showEncryptionPage)
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
@@ -1207,7 +1209,7 @@ fun GroupInfoScreen(
imageVector = Icons.Default.PersonAdd, imageVector = Icons.Default.PersonAdd,
contentDescription = null, contentDescription = null,
tint = accentColor, tint = accentColor,
modifier = Modifier.size(22.dp) modifier = Modifier.size(groupMenuTrailingIconSize)
) )
} }
@@ -1233,7 +1235,7 @@ fun GroupInfoScreen(
) )
if (encryptionKeyLoading) { if (encryptionKeyLoading) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(20.dp), modifier = Modifier.size(groupMenuTrailingIconSize),
strokeWidth = 2.dp, strokeWidth = 2.dp,
color = accentColor color = accentColor
) )
@@ -1241,12 +1243,12 @@ fun GroupInfoScreen(
val identiconKey = encryptionKey.ifBlank { dialogPublicKey } val identiconKey = encryptionKey.ifBlank { dialogPublicKey }
Box( Box(
modifier = Modifier modifier = Modifier
.size(34.dp) .size(groupMenuTrailingIconSize)
.clip(RoundedCornerShape(6.dp)) .clip(RoundedCornerShape(3.dp))
) { ) {
TelegramStyleIdenticon( TelegramStyleIdenticon(
keyRender = identiconKey, keyRender = identiconKey,
size = 34.dp, size = groupMenuTrailingIconSize,
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme
) )
} }
@@ -1565,11 +1567,12 @@ fun GroupInfoScreen(
) )
} }
AnimatedVisibility( SwipeBackContainer(
visible = showEncryptionPage, isVisible = showEncryptionPage,
enter = fadeIn(animationSpec = tween(durationMillis = 260)), onBack = { showEncryptionPage = false },
exit = fadeOut(animationSpec = tween(durationMillis = 200)), isDarkTheme = isDarkTheme,
modifier = Modifier.fillMaxSize() layer = 3,
propagateBackgroundProgress = false
) { ) {
val displayLines = remember(encryptionKey) { encodeGroupKeyForDisplay(encryptionKey) } val displayLines = remember(encryptionKey) { encodeGroupKeyForDisplay(encryptionKey) }
GroupEncryptionKeyPage( GroupEncryptionKeyPage(

View File

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

View File

@@ -32,6 +32,7 @@ import androidx.compose.material3.Icon
internal fun AttachAlertPhotoLayout( internal fun AttachAlertPhotoLayout(
state: AttachAlertUiState, state: AttachAlertUiState,
gridState: LazyGridState, gridState: LazyGridState,
cameraEnabled: Boolean = true,
onCameraClick: () -> Unit, onCameraClick: () -> Unit,
onItemClick: (MediaItem, ThumbnailPosition) -> Unit, onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
onItemCheckClick: (MediaItem) -> Unit, onItemCheckClick: (MediaItem) -> Unit,
@@ -89,6 +90,7 @@ internal fun AttachAlertPhotoLayout(
mediaItems = state.visibleMediaItems, mediaItems = state.visibleMediaItems,
selectedItemOrder = state.selectedItemOrder, selectedItemOrder = state.selectedItemOrder,
showCameraItem = state.visibleAlbum?.isAllMedia != false, showCameraItem = state.visibleAlbum?.isAllMedia != false,
cameraEnabled = cameraEnabled,
gridState = gridState, gridState = gridState,
onCameraClick = onCameraClick, onCameraClick = onCameraClick,
onItemClick = onItemClick, 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). * 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 if (!shouldShow || state.editingItem != null) return@LaunchedEffect
val window = (view.context as? Activity)?.window ?: return@LaunchedEffect val window = (view.context as? Activity)?.window ?: return@LaunchedEffect
snapshotFlow { val dark = isDarkTheme
PickerSystemBarsSnapshot( val fullScreen = isPickerFullScreen
scrimAlpha = scrimAlpha,
isFullScreen = isPickerFullScreen, if (hasNativeNavigationBar) {
isDarkTheme = isDarkTheme, val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
openProgress = (1f - animatedOffset).coerceIn(0f, 1f) window.navigationBarColor = navBaseColor
) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
}.collect { state -> window.isNavigationBarContrastEnforced = true
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
}
} }
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( AttachAlertTab.PHOTO -> AttachAlertPhotoLayout(
state = state, state = state,
gridState = mediaGridState, gridState = mediaGridState,
cameraEnabled = !isClosing,
onCameraClick = { onCameraClick = {
requestClose { requestClose {
hideKeyboard() hideKeyboard()

View File

@@ -16,6 +16,7 @@ import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
@@ -33,20 +34,26 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.rosetta.messenger.R
import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.AttachmentType
@@ -68,6 +75,7 @@ import kotlinx.coroutines.withContext
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import android.content.Intent import android.content.Intent
import android.os.SystemClock
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.File import java.io.File
@@ -333,6 +341,105 @@ enum class DownloadStatus {
ERROR 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 группируются в * Composable для отображения всех attachments в сообщении 🖼️ IMAGE attachments группируются в
* коллаж (как в Telegram) * коллаж (как в Telegram)
@@ -1454,6 +1561,8 @@ fun FileAttachment(
val context = LocalContext.current val context = LocalContext.current
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) } var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
var downloadProgress by remember { mutableStateOf(0f) } var downloadProgress by remember { mutableStateOf(0f) }
var isPaused by remember { mutableStateOf(false) }
var lastActionAtMs by remember { mutableLongStateOf(0L) }
// Bounce animation for icon // Bounce animation for icon
val iconScale = remember { Animatable(0f) } val iconScale = remember { Animatable(0f) }
@@ -1495,16 +1604,40 @@ fun FileAttachment(
downloadStatus = when (state.status) { downloadStatus = when (state.status) {
com.rosetta.messenger.network.FileDownloadStatus.QUEUED, com.rosetta.messenger.network.FileDownloadStatus.QUEUED,
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> DownloadStatus.DOWNLOADING 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.DECRYPTING -> DownloadStatus.DECRYPTING
com.rosetta.messenger.network.FileDownloadStatus.DONE -> DownloadStatus.DOWNLOADED com.rosetta.messenger.network.FileDownloadStatus.DONE -> DownloadStatus.DOWNLOADED
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> DownloadStatus.ERROR com.rosetta.messenger.network.FileDownloadStatus.ERROR -> DownloadStatus.ERROR
} }
isPaused = state.status == com.rosetta.messenger.network.FileDownloadStatus.PAUSED
} }
LaunchedEffect(attachment.id) { 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)) { if (com.rosetta.messenger.network.FileDownloadManager.isDownloading(attachment.id)) {
downloadStatus = DownloadStatus.DOWNLOADING downloadStatus = DownloadStatus.DOWNLOADING
isPaused = false
return@LaunchedEffect return@LaunchedEffect
} }
downloadStatus = if (isDownloadTag(preview)) { downloadStatus = if (isDownloadTag(preview)) {
@@ -1516,6 +1649,7 @@ fun FileAttachment(
if (savedFile.exists()) DownloadStatus.DOWNLOADED if (savedFile.exists()) DownloadStatus.DOWNLOADED
else DownloadStatus.DOWNLOADED // blob есть в памяти else DownloadStatus.DOWNLOADED // blob есть в памяти
} }
isPaused = false
} }
// Открыть файл через системное приложение // Открыть файл через системное приложение
@@ -1551,10 +1685,8 @@ fun FileAttachment(
// 📥 Запуск скачивания через глобальный FileDownloadManager // 📥 Запуск скачивания через глобальный FileDownloadManager
val download: () -> Unit = { val download: () -> Unit = {
if (downloadTag.isNotEmpty()) { if (downloadTag.isNotEmpty()) {
downloadStatus = DownloadStatus.DOWNLOADING isPaused = false
downloadProgress = 0f
com.rosetta.messenger.network.FileDownloadManager.download( com.rosetta.messenger.network.FileDownloadManager.download(
context = context,
attachmentId = attachment.id, attachmentId = attachment.id,
downloadTag = downloadTag, downloadTag = downloadTag,
chachaKey = chachaKey, 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: без внутреннего фона, просто иконка + текст // Telegram-style файл - как в desktop: без внутреннего фона, просто иконка + текст
Box(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) {
Row( Row(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled = enabled =
downloadStatus == DownloadStatus.NOT_DOWNLOADED || !isSendingUpload &&
downloadStatus == DownloadStatus.DOWNLOADED || (downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
downloadStatus == DownloadStatus.ERROR downloadStatus == DownloadStatus.DOWNLOADED ||
downloadStatus == DownloadStatus.ERROR ||
isDownloadInProgress ||
isPaused)
) { ) {
when (downloadStatus) { val now = SystemClock.elapsedRealtime()
DownloadStatus.DOWNLOADED -> openFile() if (now - lastActionAtMs < 220L) return@clickable
lastActionAtMs = now
when {
isPaused -> resumeDownload()
downloadStatus == DownloadStatus.DOWNLOADING ||
downloadStatus == DownloadStatus.DECRYPTING -> pauseDownload()
downloadStatus == DownloadStatus.DOWNLOADED -> openFile()
else -> download() else -> download()
} }
} }
@@ -1595,62 +1764,12 @@ fun FileAttachment(
}, },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// Круглый фон иконки TelegramFileActionButton(
Box( state = actionState,
modifier = progress = actionProgress,
Modifier.fillMaxSize() indeterminate = isSendingUpload,
.clip(CircleShape) modifier = Modifier.fillMaxSize()
.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)
)
}
}
}
} }
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
@@ -1679,34 +1798,52 @@ fun FileAttachment(
PrimaryBlue PrimaryBlue
} }
when (downloadStatus) { if (isSendingUpload) {
DownloadStatus.DOWNLOADING -> { AnimatedDotsText(
// Telegram-style: "1.2 MB / 5.4 MB" baseText = "Uploading",
// CDN download maps to progress 0..0.8 color = statusColor,
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f) fontSize = 12.sp
val downloadedBytes = (cdnFraction * fileSize).toLong() )
Text( } else {
text = "${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}", when (downloadStatus) {
fontSize = 12.sp, DownloadStatus.DOWNLOADING -> {
color = statusColor if (isPaused) {
) val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
} val downloadedBytes = (cdnFraction * fileSize).toLong()
DownloadStatus.DECRYPTING -> { Text(
AnimatedDotsText( text = "Paused • ${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
baseText = "Decrypting", fontSize = 12.sp,
color = statusColor, color = statusColor
fontSize = 12.sp )
) } else {
} // Telegram-style: "1.2 MB / 5.4 MB"
else -> { // CDN download maps to progress 0..0.8
Text( val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
text = when (downloadStatus) { val downloadedBytes = (cdnFraction * fileSize).toLong()
DownloadStatus.ERROR -> "File expired" Text(
else -> "${formatFileSize(fileSize)} $fileExtension" text = "${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
}, fontSize = 12.sp,
fontSize = 12.sp, color = statusColor
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

@@ -59,6 +59,7 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.PopupProperties
@@ -191,37 +192,33 @@ fun TelegramStyleMessageContent(
private data class LayoutResult(val width: Int, val height: Int, val timeX: Int, val timeY: Int) private data class LayoutResult(val width: Int, val height: Int, val timeX: Int, val timeY: Int)
/** Date header with fade-in animation */ /** Telegram-like date header chip (inline separator and floating top badge). */
@Composable @Composable
fun DateHeader(dateText: String, secondaryTextColor: Color) { fun DateHeader(
var isVisible by remember { mutableStateOf(false) } dateText: String,
val alpha by textColor: Color,
animateFloatAsState( backgroundColor: Color,
targetValue = if (isVisible) 1f else 0f, modifier: Modifier = Modifier,
animationSpec = tween(durationMillis = 250, easing = TelegramEasing), verticalPadding: Dp = 12.dp
label = "dateAlpha" ) {
)
LaunchedEffect(dateText) { isVisible = true }
Row( Row(
modifier = modifier =
Modifier.fillMaxWidth().padding(vertical = 12.dp).graphicsLayer { modifier
this.alpha = alpha .fillMaxWidth()
}, .padding(vertical = verticalPadding),
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
Text( Text(
text = dateText, text = dateText,
fontSize = 13.sp, fontSize = 12.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = secondaryTextColor, color = textColor,
modifier = modifier =
Modifier.background( Modifier.background(
color = secondaryTextColor.copy(alpha = 0.1f), color = backgroundColor,
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(10.dp)
) )
.padding(horizontal = 12.dp, vertical = 4.dp) .padding(horizontal = 10.dp, vertical = 3.dp)
) )
} }
} }
@@ -861,8 +858,14 @@ fun MessageBubble(
), ),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
val senderLabelText =
senderName
.replace('\n', ' ')
.trim()
val senderLabelMaxWidth =
if (isGroupSenderAdmin) 170.dp else 220.dp
Text( Text(
text = senderName, text = senderLabelText,
color = color =
groupSenderLabelColor( groupSenderLabelColor(
senderPublicKey, senderPublicKey,
@@ -870,6 +873,7 @@ fun MessageBubble(
), ),
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
modifier = Modifier.widthIn(max = senderLabelMaxWidth),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )

View File

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

View File

@@ -1,5 +1,6 @@
package com.rosetta.messenger.ui.components package com.rosetta.messenger.ui.components
import android.app.Activity
import android.content.Context import android.content.Context
import android.view.animation.DecelerateInterpolator import android.view.animation.DecelerateInterpolator
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
@@ -20,9 +21,11 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
// Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0) // Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0)
@@ -174,6 +177,15 @@ fun SwipeBackContainer(
val context = LocalContext.current val context = LocalContext.current
val view = LocalView.current val view = LocalView.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val dismissKeyboard: () -> Unit = {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
imm.hideSoftInputFromWindow(view.windowToken, InputMethodManager.HIDE_NOT_ALWAYS)
(view.context as? Activity)?.window?.let { window ->
WindowCompat.getInsetsController(window, view).hide(WindowInsetsCompat.Type.ime())
}
focusManager.clearFocus(force = true)
}
// Current offset: use drag offset during drag, animatable otherwise + optional enter slide // Current offset: use drag offset during drag, animatable otherwise + optional enter slide
val baseOffset = if (isDragging) dragOffset else offsetAnimatable.value val baseOffset = if (isDragging) dragOffset else offsetAnimatable.value
@@ -333,6 +345,7 @@ fun SwipeBackContainer(
var totalDragX = 0f var totalDragX = 0f
var totalDragY = 0f var totalDragY = 0f
var passedSlop = false var passedSlop = false
var keyboardHiddenForGesture = false
// deferToChildren=true: pre-slop uses Main pass so children // deferToChildren=true: pre-slop uses Main pass so children
// (e.g. LazyRow) process first — if they consume, we back off. // (e.g. LazyRow) process first — if they consume, we back off.
@@ -359,6 +372,14 @@ fun SwipeBackContainer(
totalDragX += dragDelta.x totalDragX += dragDelta.x
totalDragY += dragDelta.y totalDragY += dragDelta.y
if (!keyboardHiddenForGesture &&
totalDragX > 10f &&
kotlin.math.abs(totalDragX) >
kotlin.math.abs(totalDragY)) {
dismissKeyboard()
keyboardHiddenForGesture = true
}
if (!passedSlop) { if (!passedSlop) {
// Child (e.g. LazyRow) already consumed — let it handle // Child (e.g. LazyRow) already consumed — let it handle
if (change.isConsumed) break if (change.isConsumed) break
@@ -393,17 +414,8 @@ fun SwipeBackContainer(
screenWidthPx, screenWidthPx,
active = true active = true
) )
dismissKeyboard()
val imm = keyboardHiddenForGesture = true
context.getSystemService(
Context.INPUT_METHOD_SERVICE
) as
InputMethodManager
imm.hideSoftInputFromWindow(
view.windowToken,
0
)
focusManager.clearFocus()
change.consume() change.consume()
} else { } else {
@@ -489,6 +501,7 @@ fun SwipeBackContainer(
shouldShow = false shouldShow = false
dragOffset = 0f dragOffset = 0f
clearSharedSwipeProgressIfOwner() clearSharedSwipeProgressIfOwner()
dismissKeyboard()
onBack() onBack()
} else { } else {
offsetAnimatable.animateTo( offsetAnimatable.animateTo(

View File

@@ -108,6 +108,7 @@ import compose.icons.TablerIcons
import compose.icons.tablericons.* import compose.icons.tablericons.*
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -199,19 +200,12 @@ fun OtherProfileScreen(
var avatarViewerTimestamp by remember { mutableStateOf(0L) } var avatarViewerTimestamp by remember { mutableStateOf(0L) }
var imageViewerInitialIndex by remember { mutableIntStateOf(0) } var imageViewerInitialIndex by remember { mutableIntStateOf(0) }
var selectedTab by remember { mutableStateOf(OtherProfileTab.MEDIA) } var selectedTab by remember { mutableStateOf(OtherProfileTab.MEDIA) }
var tabSwitchJob by remember { mutableStateOf<Job?>(null) }
val pagerState = rememberPagerState( val pagerState = rememberPagerState(
initialPage = 0, initialPage = 0,
pageCount = { OtherProfileTab.entries.size } pageCount = { OtherProfileTab.entries.size }
) )
// Tab click → animate pager
LaunchedEffect(selectedTab) {
val page = OtherProfileTab.entries.indexOf(selectedTab)
if (pagerState.currentPage != page) {
pagerState.animateScrollToPage(page)
}
}
// Pager swipe → update tab + control swipe-back // Pager swipe → update tab + control swipe-back
LaunchedEffect(pagerState) { LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page -> snapshotFlow { pagerState.currentPage }.collect { page ->
@@ -826,7 +820,15 @@ fun OtherProfileScreen(
) { ) {
OtherProfileSharedTabs( OtherProfileSharedTabs(
selectedTab = selectedTab, selectedTab = selectedTab,
onTabSelected = { tab -> selectedTab = tab }, onTabSelected = { tab ->
val targetPage = OtherProfileTab.entries.indexOf(tab)
if (targetPage == -1) return@OtherProfileSharedTabs
selectedTab = tab
tabSwitchJob?.cancel()
tabSwitchJob = coroutineScope.launch {
runCatching { pagerState.animateScrollToPage(targetPage) }
}
},
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme
) )
} }

View File

@@ -281,6 +281,7 @@ fun ThemeScreen(
WallpaperSelectorRow( WallpaperSelectorRow(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
selectedWallpaperId = wallpaperId, selectedWallpaperId = wallpaperId,
wallpapers = ThemeWallpapers.forTheme(isDarkTheme),
textColor = textColor, textColor = textColor,
secondaryTextColor = secondaryTextColor, secondaryTextColor = secondaryTextColor,
onWallpaperSelected = { selectedId -> onWallpaperSelected = { selectedId ->
@@ -292,7 +293,12 @@ fun ThemeScreen(
) )
TelegramInfoText( TelegramInfoText(
text = "Selected wallpaper is used for chat backgrounds.", text =
if (isDarkTheme) {
"Showing wallpapers for dark theme. Switch to light mode to choose light wallpapers."
} else {
"Showing wallpapers for light theme. Switch to dark mode to choose dark wallpapers."
},
secondaryTextColor = secondaryTextColor secondaryTextColor = secondaryTextColor
) )
@@ -454,6 +460,7 @@ private fun TelegramInfoText(text: String, secondaryTextColor: Color) {
private fun WallpaperSelectorRow( private fun WallpaperSelectorRow(
isDarkTheme: Boolean, isDarkTheme: Boolean,
selectedWallpaperId: String, selectedWallpaperId: String,
wallpapers: List<ThemeWallpaper>,
textColor: Color, textColor: Color,
secondaryTextColor: Color, secondaryTextColor: Color,
onWallpaperSelected: (String) -> Unit onWallpaperSelected: (String) -> Unit
@@ -475,7 +482,7 @@ private fun WallpaperSelectorRow(
) )
} }
items(items = ThemeWallpapers.all, key = { it.id }) { wallpaper -> items(items = wallpapers, key = { it.id }) { wallpaper ->
WallpaperSelectorItem( WallpaperSelectorItem(
title = wallpaper.name, title = wallpaper.name,
wallpaperResId = wallpaper.drawableRes, wallpaperResId = wallpaper.drawableRes,

View File

@@ -6,28 +6,94 @@ import com.rosetta.messenger.R
data class ThemeWallpaper( data class ThemeWallpaper(
val id: String, val id: String,
val name: String, val name: String,
val preferredTheme: WallpaperTheme,
@DrawableRes val drawableRes: Int @DrawableRes val drawableRes: Int
) )
enum class WallpaperTheme {
DARK,
LIGHT
}
object ThemeWallpapers { object ThemeWallpapers {
// Desktop parity: keep the same order/mapping as desktop WALLPAPERS. // Desktop parity: keep the same order/mapping as desktop WALLPAPERS.
val all: List<ThemeWallpaper> = val all: List<ThemeWallpaper> =
listOf( listOf(
ThemeWallpaper(id = "back_3", name = "Wallpaper 1", drawableRes = R.drawable.wallpaper_back_3), ThemeWallpaper(
ThemeWallpaper(id = "back_4", name = "Wallpaper 2", drawableRes = R.drawable.wallpaper_back_4), id = "back_3",
ThemeWallpaper(id = "back_5", name = "Wallpaper 3", drawableRes = R.drawable.wallpaper_back_5), name = "Wallpaper 1",
ThemeWallpaper(id = "back_6", name = "Wallpaper 4", drawableRes = R.drawable.wallpaper_back_6), preferredTheme = WallpaperTheme.DARK,
ThemeWallpaper(id = "back_7", name = "Wallpaper 5", drawableRes = R.drawable.wallpaper_back_7), drawableRes = R.drawable.wallpaper_back_3
ThemeWallpaper(id = "back_8", name = "Wallpaper 6", drawableRes = R.drawable.wallpaper_back_8), ),
ThemeWallpaper(id = "back_9", name = "Wallpaper 7", drawableRes = R.drawable.wallpaper_back_9), ThemeWallpaper(
ThemeWallpaper(id = "back_10", name = "Wallpaper 8", drawableRes = R.drawable.wallpaper_back_10), id = "back_4",
ThemeWallpaper(id = "back_11", name = "Wallpaper 9", drawableRes = R.drawable.wallpaper_back_11), name = "Wallpaper 2",
ThemeWallpaper(id = "back_1", name = "Wallpaper 10", drawableRes = R.drawable.wallpaper_back_1), preferredTheme = WallpaperTheme.LIGHT,
ThemeWallpaper(id = "back_2", name = "Wallpaper 11", drawableRes = R.drawable.wallpaper_back_2) drawableRes = R.drawable.wallpaper_back_4
),
ThemeWallpaper(
id = "back_5",
name = "Wallpaper 3",
preferredTheme = WallpaperTheme.DARK,
drawableRes = R.drawable.wallpaper_back_5
),
ThemeWallpaper(
id = "back_6",
name = "Wallpaper 4",
preferredTheme = WallpaperTheme.LIGHT,
drawableRes = R.drawable.wallpaper_back_6
),
ThemeWallpaper(
id = "back_7",
name = "Wallpaper 5",
preferredTheme = WallpaperTheme.LIGHT,
drawableRes = R.drawable.wallpaper_back_7
),
ThemeWallpaper(
id = "back_8",
name = "Wallpaper 6",
preferredTheme = WallpaperTheme.LIGHT,
drawableRes = R.drawable.wallpaper_back_8
),
ThemeWallpaper(
id = "back_9",
name = "Wallpaper 7",
preferredTheme = WallpaperTheme.LIGHT,
drawableRes = R.drawable.wallpaper_back_9
),
ThemeWallpaper(
id = "back_10",
name = "Wallpaper 8",
preferredTheme = WallpaperTheme.LIGHT,
drawableRes = R.drawable.wallpaper_back_10
),
ThemeWallpaper(
id = "back_11",
name = "Wallpaper 9",
preferredTheme = WallpaperTheme.DARK,
drawableRes = R.drawable.wallpaper_back_11
),
ThemeWallpaper(
id = "back_1",
name = "Wallpaper 10",
preferredTheme = WallpaperTheme.LIGHT,
drawableRes = R.drawable.wallpaper_back_1
),
ThemeWallpaper(
id = "back_2",
name = "Wallpaper 11",
preferredTheme = WallpaperTheme.DARK,
drawableRes = R.drawable.wallpaper_back_2
)
) )
fun findById(id: String): ThemeWallpaper? = all.firstOrNull { it.id == id } fun findById(id: String): ThemeWallpaper? = all.firstOrNull { it.id == id }
fun forTheme(isDarkTheme: Boolean): List<ThemeWallpaper> {
val targetTheme = if (isDarkTheme) WallpaperTheme.DARK else WallpaperTheme.LIGHT
return all.filter { it.preferredTheme == targetTheme }
}
@DrawableRes @DrawableRes
fun drawableResOrNull(id: String): Int? = findById(id)?.drawableRes fun drawableResOrNull(id: String): Int? = findById(id)?.drawableRes
} }