feat: enhance avatar handling and file attachment functionality with improved UI interactions

This commit is contained in:
2026-02-15 11:54:09 +05:00
parent e301478d92
commit b543ef4d6f
11 changed files with 177 additions and 101 deletions

View File

@@ -58,7 +58,6 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
@@ -214,8 +213,11 @@ class MainActivity : FragmentActivity() {
else -> "main"
},
transitionSpec = {
fadeIn(animationSpec = tween(600)) togetherWith
fadeOut(animationSpec = tween(600))
// Новый экран плавно появляется ПОВЕРХ старого.
// Старый остаётся видимым (alpha=1) пока новый не готов →
// нет белой вспышки от Surface.
fadeIn(animationSpec = tween(350)) togetherWith
fadeOut(animationSpec = tween(1, delayMillis = 350))
},
label = "screenTransition"
) { screen ->
@@ -603,14 +605,13 @@ fun MainScreen(
// Состояние протокола для передачи в SearchScreen
val protocolState by ProtocolManager.state.collectAsState()
// Перечитать username/name после получения own profile с сервера
// Аналог Desktop: useUserInformation автоматически обновляет UI при PacketSearch ответе
LaunchedEffect(protocolState) {
if (protocolState == ProtocolState.AUTHENTICATED &&
// Реактивно обновляем username/name когда сервер отвечает на fetchOwnProfile()
val ownProfileUpdated by ProtocolManager.ownProfileUpdated.collectAsState()
LaunchedEffect(ownProfileUpdated) {
if (ownProfileUpdated > 0L &&
accountPublicKey.isNotBlank() &&
accountPublicKey != "04c266b98ae5"
) {
delay(2000) // Ждём fetchOwnProfile() → PacketSearch → AccountManager update
val accountManager = AccountManager(context)
val encryptedAccount = accountManager.getAccount(accountPublicKey)
accountUsername = encryptedAccount?.username ?: ""

View File

@@ -139,9 +139,15 @@ class MessageRepository private constructor(private val context: Context) {
/** Инициализация с текущим аккаунтом */
fun initialize(publicKey: String, privateKey: String) {
val start = System.currentTimeMillis()
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
// 🔥 Очищаем кэши при смене аккаунта
if (currentAccount != publicKey) {
requestedUserInfoKeys.clear()
// Clear message Flow cache — dialogKey is account-independent (sorted pair),
// so stale Flows from previous account would return wrong data
messageCache.clear()
// Clear processed messageIds — prevents blocking delivery of messages
// that were already processed under the previous account
clearProcessedCache()
}
currentAccount = publicKey

View File

@@ -313,17 +313,29 @@ class Protocol(
log(" Public key: ${publicKey.take(20)}...")
log(" Private hash: ${privateHash.take(20)}...")
log(" Current state: ${_state.value}")
// Detect account switch: already authenticated but with different credentials
val switchingAccount = (_state.value == ProtocolState.AUTHENTICATED || _state.value == ProtocolState.HANDSHAKING) &&
lastPublicKey != null && lastPublicKey != publicKey
// Save credentials for reconnection
lastPublicKey = publicKey
lastPrivateHash = privateHash
// If switching accounts, force disconnect and reconnect with new credentials
if (switchingAccount) {
log("🔄 Account switch detected, forcing reconnect with new credentials")
disconnect()
connect() // Will auto-handshake with saved credentials (publicKey, privateHash) on connect
return
}
// КРИТИЧНО: если уже в handshake или authenticated, не начинаем заново
if (_state.value == ProtocolState.HANDSHAKING) {
log("⚠️ HANDSHAKE IGNORED: Already handshaking")
return
}
if (_state.value == ProtocolState.AUTHENTICATED) {
log("⚠️ HANDSHAKE IGNORED: Already authenticated")
return

View File

@@ -35,6 +35,10 @@ object ProtocolManager {
// Typing status
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow()
// Сигнал обновления own profile (username/name загружены с сервера)
private val _ownProfileUpdated = MutableStateFlow(0L)
val ownProfileUpdated: StateFlow<Long> = _ownProfileUpdated.asStateFlow()
// 🔍 Global user info cache (like Desktop's InformationProvider.cachedUsers)
// publicKey → SearchUser (resolved via PacketSearch 0x03)
@@ -200,6 +204,7 @@ object ProtocolManager {
if (user.username.isNotBlank()) {
accountManager.updateAccountUsername(user.publicKey, user.username)
}
_ownProfileUpdated.value = System.currentTimeMillis()
}
}
}

View File

@@ -154,15 +154,19 @@ class AuthStateManager(
_state.update { it.copy(
status = AuthStatus.Authenticated(decryptedAccount)
)}
loadAccounts()
// Initialize MessageRepository BEFORE connecting/authenticating
// so incoming messages from server are stored under the correct account
ProtocolManager.initializeAccount(keyPair.publicKey, keyPair.privateKey)
// Step 8: Connect and authenticate with protocol
ProtocolManager.connect()
// Give WebSocket time to connect before authenticating
kotlinx.coroutines.delay(500)
ProtocolManager.authenticate(keyPair.publicKey, privateKeyHash)
Result.success(decryptedAccount)
@@ -199,13 +203,17 @@ class AuthStateManager(
_state.update { it.copy(
status = AuthStatus.Authenticated(decryptedAccount)
)}
// Initialize MessageRepository BEFORE connecting/authenticating
// so incoming messages from server are stored under the correct account
ProtocolManager.initializeAccount(decryptedAccount.publicKey, decryptedAccount.privateKey)
// Connect and authenticate with protocol
ProtocolManager.connect()
// Give WebSocket time to connect before authenticating
kotlinx.coroutines.delay(500)
ProtocolManager.authenticate(decryptedAccount.publicKey, decryptedAccount.privateKeyHash)
Result.success(decryptedAccount)

View File

@@ -72,7 +72,8 @@ fun ImportSeedPhraseScreen(
modifier = Modifier
.fillMaxSize()
.imePadding()
.padding(horizontal = 24.dp),
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(16.dp))
@@ -233,8 +234,8 @@ fun ImportSeedPhraseScreen(
}
}
Spacer(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.height(24.dp))
// Import button
AnimatedVisibility(
visible = visible,

View File

@@ -43,15 +43,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (dialogKey -> List<ChatMessage>)
// Сделан глобальным чтобы можно было очистить при удалении диалога
// 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (account|dialogKey -> List<ChatMessage>)
// Ключ включает account для изоляции данных между аккаунтами
private val dialogMessagesCache = ConcurrentHashMap<String, List<ChatMessage>>()
/** Формирует ключ кэша с привязкой к аккаунту */
private fun cacheKey(account: String, dialogKey: String) = "$account|$dialogKey"
/**
* 🔥 Обновить кэш с ограничением размера Сохраняет только последние MAX_CACHE_SIZE
* сообщений для предотвращения OOM
*/
private fun updateCacheWithLimit(dialogKey: String, messages: List<ChatMessage>) {
private fun updateCacheWithLimit(account: String, dialogKey: String, messages: List<ChatMessage>) {
val limitedMessages =
if (messages.size > MAX_CACHE_SIZE) {
// Оставляем только последние сообщения (по timestamp)
@@ -61,12 +64,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} else {
messages
}
dialogMessagesCache[dialogKey] = limitedMessages
dialogMessagesCache[cacheKey(account, dialogKey)] = limitedMessages
}
/** 🗑️ Очистить кэш сообщений для диалога Вызывается при удалении диалога */
fun clearDialogCache(dialogKey: String) {
dialogMessagesCache.remove(dialogKey)
// Очищаем для всех аккаунтов — при удалении диалога dialogKey не привязан к аккаунту
val keysToRemove = dialogMessagesCache.keys.filter { it.endsWith("|$dialogKey") }
keysToRemove.forEach { dialogMessagesCache.remove(it) }
}
/** 🗑️ Очистить кэш по publicKey собеседника Удаляет все ключи содержащие этот publicKey */
@@ -74,6 +79,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val keysToRemove = dialogMessagesCache.keys.filter { it.contains(opponentKey) }
keysToRemove.forEach { dialogMessagesCache.remove(it) }
}
/** 🗑️ Полная очистка кэша — вызывается при переключении аккаунта */
fun clearAllDialogCache() {
dialogMessagesCache.clear()
}
}
// Database
@@ -322,11 +332,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
// Обновляем кэш
val cachedMessages = dialogMessagesCache[dialogKey]
val cachedMessages = dialogMessagesCache[cacheKey(account, dialogKey)]
if (cachedMessages != null) {
updateCacheWithLimit(dialogKey, cachedMessages + newMessages)
updateCacheWithLimit(account, dialogKey, cachedMessages + newMessages)
} else {
updateCacheWithLimit(dialogKey, _messages.value)
updateCacheWithLimit(account, dialogKey, _messages.value)
}
currentOffset += newMessages.size
@@ -450,7 +460,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val account = myPublicKey ?: return
val opponent = opponentKey ?: return
val dialogKey = getDialogKey(account, opponent)
updateCacheWithLimit(dialogKey, _messages.value)
updateCacheWithLimit(account, dialogKey, _messages.value)
}
/** Обновить статус сообщения в БД */
@@ -480,6 +490,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/** Установить ключи пользователя */
fun setUserKeys(publicKey: String, privateKey: String) {
if (myPublicKey != publicKey) {
// Clear caches on account switch to prevent cross-account data leakage
// Безусловная очистка (даже если myPublicKey == null) — свежий ViewModel
// может получить стейтный кэш от предыдущего аккаунта
dialogMessagesCache.clear()
decryptionCache.clear()
}
myPublicKey = publicKey
myPrivateKey = privateKey
}
@@ -510,7 +527,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val cachedMessages =
if (account != null) {
val dialogKey = getDialogKey(account, publicKey)
dialogMessagesCache[dialogKey]
dialogMessagesCache[cacheKey(account, dialogKey)]
} else null
// Сбрасываем состояние
@@ -602,7 +619,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
viewModelScope.launch(Dispatchers.IO) {
try {
// 🔥 МГНОВЕННАЯ загрузка из кэша если есть!
val cachedMessages = dialogMessagesCache[dialogKey]
val cachedMessages = dialogMessagesCache[cacheKey(account, dialogKey)]
if (cachedMessages != null && cachedMessages.isNotEmpty()) {
withContext(Dispatchers.Main.immediate) {
@@ -687,7 +704,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
// 🔥 Сохраняем в кэш для мгновенной повторной загрузки!
updateCacheWithLimit(dialogKey, messages.toList())
updateCacheWithLimit(account, dialogKey, messages.toList())
// 🔥 СРАЗУ обновляем UI - пользователь видит сообщения мгновенно
// НО сохраняем оптимистичные сообщения (SENDING), которые ещё не в БД
@@ -787,11 +804,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш сохраняя ВСЕ сообщения, не только отображаемые!
// Объединяем существующий кэш с новыми сообщениями
val existingCache = dialogMessagesCache[dialogKey] ?: emptyList()
val existingCache = dialogMessagesCache[cacheKey(account, dialogKey)] ?: emptyList()
val allCachedIds = existingCache.map { it.id }.toSet()
val trulyNewMessages = newMessages.filter { it.id !in allCachedIds }
if (trulyNewMessages.isNotEmpty()) {
updateCacheWithLimit(
account,
dialogKey,
(existingCache + trulyNewMessages).sortedBy { it.timestamp }
)
@@ -881,11 +899,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш при загрузке старых сообщений!
// Это предотвращает потерю сообщений при повторном открытии диалога
val existingCache = dialogMessagesCache[dialogKey] ?: emptyList()
val existingCache = dialogMessagesCache[cacheKey(account, dialogKey)] ?: emptyList()
val allCachedIds = existingCache.map { it.id }.toSet()
val trulyNewMessages = newMessages.filter { it.id !in allCachedIds }
if (trulyNewMessages.isNotEmpty()) {
updateCacheWithLimit(
account,
dialogKey,
(trulyNewMessages + existingCache).sortedBy { it.timestamp }
)
@@ -3558,7 +3577,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Очищаем кэш
val dialogKey = getDialogKey(account, opponent)
dialogMessagesCache.remove(dialogKey)
dialogMessagesCache.remove(cacheKey(account, dialogKey))
// Очищаем UI
withContext(Dispatchers.Main) { _messages.value = emptyList() }

View File

@@ -49,11 +49,9 @@ import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.onboarding.PrimaryBlueDark
import com.rosetta.messenger.ui.settings.BackgroundBlurPresets
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.graphics.painter.Painter
@@ -494,29 +492,15 @@ fun ChatsListScreen(
)
val headerColor = avatarColors.backgroundColor
// Header: avatar blur или цвет шапки chat list
// Header: цвет шапки сайдбара
Box(modifier = Modifier.fillMaxWidth()) {
if (backgroundBlurColorId == "avatar") {
// Avatar blur
BlurredAvatarBackground(
publicKey = accountPublicKey,
avatarRepository = avatarRepository,
fallbackColor = if (isDarkTheme) Color(0xFF2A2A2A) else PrimaryBlue,
blurRadius = 40f,
alpha = 0.6f,
overlayColors = null,
isDarkTheme = isDarkTheme
)
} else {
// None или любой другой — стандартный цвет шапки
Box(
modifier = Modifier
.matchParentSize()
.background(
if (isDarkTheme) Color(0xFF2A2A2A) else PrimaryBlue
)
)
}
Box(
modifier = Modifier
.matchParentSize()
.background(
if (isDarkTheme) Color(0xFF2A2A2A) else PrimaryBlue
)
)
// Content поверх фона
Column(
@@ -1277,21 +1261,7 @@ fun ChatsListScreen(
val requests = chatsState.requests
val requestsCount = chatsState.requestsCount
// 🔥 ИСПРАВЛЕНИЕ МЕРЦАНИЯ: Запоминаем, что контент УЖЕ был
// показан
// Это предотвращает показ EmptyState при временных пустых
// обновлениях
var hasShownContent by rememberSaveable {
mutableStateOf(false)
}
if (chatsState.hasContent) {
hasShownContent = true
}
// 🎯 Показываем Empty State только если контент НИКОГДА не
// показывался
val shouldShowEmptyState =
chatsState.isEmpty && !hasShownContent
val showSkeleton = isLoading
// 🎬 Animated content transition between main list and
// requests
@@ -1427,12 +1397,9 @@ fun ChatsListScreen(
}
)
} // Close Box wrapper
} else if (isLoading) {
// 🚀 Shimmer skeleton пока данные грузятся
} else if (showSkeleton) {
ChatsListSkeleton(isDarkTheme = isDarkTheme)
} else if (shouldShowEmptyState) {
// 🔥 Empty state - показываем только если
// контент НЕ был показан ранее
} else if (chatsState.isEmpty) {
EmptyChatsState(
isDarkTheme = isDarkTheme,
modifier = Modifier.fillMaxSize()

View File

@@ -14,6 +14,7 @@ import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.*
@@ -73,6 +74,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 🟢 Tracking для уже подписанных онлайн-статусов - предотвращает бесконечный цикл
private val subscribedOnlineKeys = mutableSetOf<String>()
// Job для отмены подписок при смене аккаунта
private var accountSubscriptionsJob: Job? = null
// Список диалогов с расшифрованными сообщениями
private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList())
val dialogs: StateFlow<List<DialogUiModel>> = _dialogs.asStateFlow()
@@ -124,16 +128,32 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
requestedUserInfoKeys.clear()
subscribedOnlineKeys.clear()
// 🔥 Очищаем глобальный кэш сообщений ChatViewModel при смене аккаунта
// чтобы избежать показа сообщений с неправильным isOutgoing
ChatViewModel.clearAllDialogCache()
// Отменяем старые подписки от предыдущего аккаунта
accountSubscriptionsJob?.cancel()
currentAccount = publicKey
currentPrivateKey = privateKey
// 🔥 Очищаем устаревшие данные от предыдущего аккаунта
_dialogs.value = emptyList()
_requests.value = emptyList()
_requestsCount.value = 0
// 🚀 Pre-warm PBKDF2 ключ — чтобы к моменту дешифровки кэш был горячий
viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(privateKey) }
// Запускаем все подписки в одном родительском Job для отмены при смене аккаунта
accountSubscriptionsJob = viewModelScope.launch {
// Подписываемся на обычные диалоги
@OptIn(FlowPreview::class)
viewModelScope.launch {
launch {
dialogDao
.getDialogsFlow(publicKey)
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
@@ -293,7 +313,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 📬 Подписываемся на requests (запросы от новых пользователей)
@OptIn(FlowPreview::class)
viewModelScope.launch {
launch {
dialogDao
.getRequestsFlow(publicKey)
.flowOn(Dispatchers.IO)
@@ -422,7 +442,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
// 📊 Подписываемся на количество requests
viewModelScope.launch {
launch {
dialogDao
.getRequestsCountFlow(publicKey)
.flowOn(Dispatchers.IO)
@@ -432,7 +452,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 🔥 Подписываемся на блоклист — Room автоматически переэмитит при
// blockUser()/unblockUser()
viewModelScope.launch {
launch {
database.blacklistDao()
.getBlockedUsers(publicKey)
.flowOn(Dispatchers.IO)
@@ -440,6 +460,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
.distinctUntilChanged()
.collect { blockedSet -> _blockedUsers.value = blockedSet }
}
} // end accountSubscriptionsJob
}
/**

View File

@@ -64,7 +64,11 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.content.FileProvider
import android.content.Intent
import android.webkit.MimeTypeMap
import java.io.ByteArrayInputStream
import java.io.File
import kotlin.math.min
private const val TAG = "AttachmentComponents"
@@ -1344,13 +1348,37 @@ fun FileAttachment(
label = "progress"
)
// Путь к скачанному файлу (как Desktop: ~/Rosetta Downloads/filename)
val downloadsDir = remember { File(context.filesDir, "rosetta_downloads").apply { mkdirs() } }
val savedFile = remember(fileName) { File(downloadsDir, fileName) }
LaunchedEffect(attachment.id) {
downloadStatus =
if (isDownloadTag(preview)) {
DownloadStatus.NOT_DOWNLOADED
} else {
DownloadStatus.DOWNLOADED
}
downloadStatus = if (isDownloadTag(preview)) {
// Проверяем, был ли файл уже скачан ранее
if (savedFile.exists()) DownloadStatus.DOWNLOADED
else DownloadStatus.NOT_DOWNLOADED
} else {
DownloadStatus.DOWNLOADED
}
}
// Открыть файл через системное приложение
val openFile: () -> Unit = {
try {
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
savedFile
)
val ext = fileName.substringAfterLast('.', "").lowercase()
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)
?: "application/octet-stream"
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
} catch (_: Exception) {}
}
val download: () -> Unit = {
@@ -1364,8 +1392,6 @@ fun FileAttachment(
downloadStatus = DownloadStatus.DECRYPTING
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
// Сначала расшифровываем его, получаем raw bytes
val decryptedKeyAndNonce =
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
@@ -1377,7 +1403,16 @@ fun FileAttachment(
downloadProgress = 0.9f
if (decrypted != null) {
// TODO: Save to Downloads folder
withContext(Dispatchers.IO) {
// Декодим base64 в байты (обработка data URL и plain base64)
val base64Data = if (decrypted.contains(",")) {
decrypted.substringAfter(",")
} else {
decrypted
}
val bytes = Base64.decode(base64Data, Base64.DEFAULT)
savedFile.writeBytes(bytes)
}
downloadProgress = 1f
downloadStatus = DownloadStatus.DOWNLOADED
} else {
@@ -1398,8 +1433,14 @@ fun FileAttachment(
.clickable(
enabled =
downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
downloadStatus == DownloadStatus.DOWNLOADED ||
downloadStatus == DownloadStatus.ERROR
) { download() }
) {
when (downloadStatus) {
DownloadStatus.DOWNLOADED -> openFile()
else -> download()
}
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {

View File

@@ -42,12 +42,6 @@ fun BoxScope.BlurredAvatarBackground(
overlayColors: List<Color>? = null,
isDarkTheme: Boolean = true
) {
// В светлой теме с дефолтным фоном (avatar, без overlay) — синий как шапка chat list
if (!isDarkTheme && (overlayColors == null || overlayColors.isEmpty())) {
Box(modifier = Modifier.matchParentSize().background(Color(0xFF0D8CF4)))
return
}
// Если выбран цвет в Appearance — рисуем блюр аватарки + полупрозрачный overlay поверх
// (одинаково для светлой и тёмной темы, чтобы цвет совпадал с превью в Appearance)
if (overlayColors != null && overlayColors.isNotEmpty()) {