Промежуточный результат для 1.0.4 версии

This commit is contained in:
2026-02-22 08:54:46 +05:00
parent 3aa18fa9ac
commit 5b9b3f83f7
37 changed files with 5643 additions and 928 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.0.3" val rosettaVersionName = "1.0.4"
val rosettaVersionCode = 3 // Increment on each release val rosettaVersionCode = 4 // Increment on each release
android { android {
namespace = "com.rosetta.messenger" namespace = "com.rosetta.messenger"

View File

@@ -153,6 +153,7 @@ class MainActivity : FragmentActivity() {
var hasExistingAccount by remember { mutableStateOf<Boolean?>(null) } var hasExistingAccount by remember { mutableStateOf<Boolean?>(null) }
var currentAccount by remember { mutableStateOf<DecryptedAccount?>(null) } var currentAccount by remember { mutableStateOf<DecryptedAccount?>(null) }
var accountInfoList by remember { mutableStateOf<List<AccountInfo>>(emptyList()) } var accountInfoList by remember { mutableStateOf<List<AccountInfo>>(emptyList()) }
var startCreateAccountFlow by remember { mutableStateOf(false) }
// Check for existing accounts and build AccountInfo list // Check for existing accounts and build AccountInfo list
// Also force logout so user always sees unlock screen on app restart // Also force logout so user always sees unlock screen on app restart
@@ -242,7 +243,9 @@ class MainActivity : FragmentActivity() {
hasExistingAccount = screen == "auth_unlock", hasExistingAccount = screen == "auth_unlock",
accounts = accountInfoList, accounts = accountInfoList,
accountManager = accountManager, accountManager = accountManager,
startInCreateMode = startCreateAccountFlow,
onAuthComplete = { account -> onAuthComplete = { account ->
startCreateAccountFlow = false
currentAccount = account currentAccount = account
hasExistingAccount = true hasExistingAccount = true
// Save as last logged account // Save as last logged account
@@ -261,6 +264,7 @@ class MainActivity : FragmentActivity() {
} }
}, },
onLogout = { onLogout = {
startCreateAccountFlow = false
// Set currentAccount to null immediately to prevent UI // Set currentAccount to null immediately to prevent UI
// lag // lag
currentAccount = null currentAccount = null
@@ -287,6 +291,7 @@ class MainActivity : FragmentActivity() {
scope.launch { preferencesManager.setThemeMode(mode) } scope.launch { preferencesManager.setThemeMode(mode) }
}, },
onLogout = { onLogout = {
startCreateAccountFlow = false
// Set currentAccount to null immediately to prevent UI // Set currentAccount to null immediately to prevent UI
// lag // lag
currentAccount = null currentAccount = null
@@ -297,6 +302,7 @@ class MainActivity : FragmentActivity() {
} }
}, },
onDeleteAccount = { onDeleteAccount = {
startCreateAccountFlow = false
val publicKey = currentAccount?.publicKey ?: return@MainScreen val publicKey = currentAccount?.publicKey ?: return@MainScreen
scope.launch { scope.launch {
try { try {
@@ -331,14 +337,59 @@ class MainActivity : FragmentActivity() {
val accounts = accountManager.getAllAccounts() val accounts = accountManager.getAllAccounts()
accountInfoList = accounts.map { it.toAccountInfo() } accountInfoList = accounts.map { it.toAccountInfo() }
}, },
onDeleteAccountFromSidebar = { targetPublicKey ->
startCreateAccountFlow = false
scope.launch {
try {
val database = RosettaDatabase.getDatabase(this@MainActivity)
// 1. Delete all messages
database.messageDao().deleteAllByAccount(targetPublicKey)
// 2. Delete all dialogs
database.dialogDao().deleteAllByAccount(targetPublicKey)
// 3. Delete blacklist
database.blacklistDao().deleteAllByAccount(targetPublicKey)
// 4. Delete avatars from DB
database.avatarDao().deleteAvatars(targetPublicKey)
// 5. Delete account from Room DB
database.accountDao().deleteAccount(targetPublicKey)
// 6. Disconnect protocol only if deleting currently open account
if (currentAccount?.publicKey == targetPublicKey) {
com.rosetta.messenger.network.ProtocolManager.disconnect()
}
// 7. Delete account from AccountManager DataStore
accountManager.deleteAccount(targetPublicKey)
// 8. Refresh accounts list
val accounts = accountManager.getAllAccounts()
accountInfoList = accounts.map { it.toAccountInfo() }
hasExistingAccount = accounts.isNotEmpty()
// 9. If current account is deleted, return to main login screen
if (currentAccount?.publicKey == targetPublicKey) {
currentAccount = null
}
} catch (e: Exception) {
android.util.Log.e("DeleteAccount", "Failed to delete account from sidebar", e)
}
}
},
onSwitchAccount = { targetPublicKey -> onSwitchAccount = { targetPublicKey ->
// Switch to another account: logout current, then auto-login target startCreateAccountFlow = false
// Save target account before leaving main screen so Unlock
// screen preselects the account the user tapped.
accountManager.setLastLoggedPublicKey(targetPublicKey)
// Switch to another account: logout current, then show unlock.
currentAccount = null
scope.launch {
com.rosetta.messenger.network.ProtocolManager.disconnect()
accountManager.logout()
}
},
onAddAccount = {
startCreateAccountFlow = true
currentAccount = null currentAccount = null
scope.launch { scope.launch {
com.rosetta.messenger.network.ProtocolManager.disconnect() com.rosetta.messenger.network.ProtocolManager.disconnect()
accountManager.logout() accountManager.logout()
// Set the target account as last logged so UnlockScreen picks it up
accountManager.setLastLoggedPublicKey(targetPublicKey)
} }
} }
) )
@@ -529,7 +580,9 @@ fun MainScreen(
onLogout: () -> Unit = {}, onLogout: () -> Unit = {},
onDeleteAccount: () -> Unit = {}, onDeleteAccount: () -> Unit = {},
onAccountInfoUpdated: suspend () -> Unit = {}, onAccountInfoUpdated: suspend () -> Unit = {},
onSwitchAccount: (String) -> Unit = {} onSwitchAccount: (String) -> Unit = {},
onDeleteAccountFromSidebar: (String) -> Unit = {},
onAddAccount: () -> Unit = {}
) { ) {
val accountPublicKey = account?.publicKey.orEmpty() val accountPublicKey = account?.publicKey.orEmpty()
@@ -739,12 +792,11 @@ fun MainScreen(
}, },
chatsViewModel = chatsListViewModel, chatsViewModel = chatsListViewModel,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
onLogout = onLogout,
onAddAccount = { onAddAccount = {
// Logout current account and go to auth screen to add new one onAddAccount()
onLogout()
}, },
onSwitchAccount = onSwitchAccount onSwitchAccount = onSwitchAccount,
onDeleteAccountFromSidebar = onDeleteAccountFromSidebar
) )
SwipeBackContainer( SwipeBackContainer(

View File

@@ -1,6 +1,7 @@
package com.rosetta.messenger package com.rosetta.messenger
import android.app.Application import android.app.Application
import com.rosetta.messenger.data.DraftManager
import com.rosetta.messenger.utils.CrashReportManager import com.rosetta.messenger.utils.CrashReportManager
/** /**
@@ -19,6 +20,9 @@ class RosettaApplication : Application() {
// Инициализируем crash reporter // Инициализируем crash reporter
initCrashReporting() initCrashReporting()
// Инициализируем менеджер черновиков
DraftManager.init(this)
} }
/** /**

View File

@@ -4,7 +4,11 @@ private const val PLACEHOLDER_ACCOUNT_NAME = "Account"
fun resolveAccountDisplayName(publicKey: String, name: String?, username: String?): String { fun resolveAccountDisplayName(publicKey: String, name: String?, username: String?): String {
val normalizedName = name?.trim().orEmpty() val normalizedName = name?.trim().orEmpty()
if (normalizedName.isNotEmpty() && !normalizedName.equals(PLACEHOLDER_ACCOUNT_NAME, ignoreCase = true)) { if (
normalizedName.isNotEmpty() &&
!normalizedName.equals(PLACEHOLDER_ACCOUNT_NAME, ignoreCase = true) &&
!looksLikePublicKeyAlias(normalizedName, publicKey)
) {
return normalizedName return normalizedName
} }
@@ -24,5 +28,19 @@ fun resolveAccountDisplayName(publicKey: String, name: String?, username: String
} }
} }
private fun looksLikePublicKeyAlias(name: String, publicKey: String): Boolean {
if (publicKey.isBlank()) return false
if (name.equals(publicKey, ignoreCase = true)) return true
val compactKeyAlias =
if (publicKey.length > 12) "${publicKey.take(6)}...${publicKey.takeLast(4)}" else publicKey
if (name.equals(compactKeyAlias, ignoreCase = true)) return true
val normalized = name.lowercase()
val prefix = publicKey.take(6).lowercase()
val suffix = publicKey.takeLast(4).lowercase()
return normalized.startsWith(prefix) && normalized.endsWith(suffix) && normalized.contains("...")
}
fun isPlaceholderAccountName(name: String?): Boolean = fun isPlaceholderAccountName(name: String?): Boolean =
name?.trim().orEmpty().equals(PLACEHOLDER_ACCOUNT_NAME, ignoreCase = true) name?.trim().orEmpty().equals(PLACEHOLDER_ACCOUNT_NAME, ignoreCase = true)

View File

@@ -0,0 +1,104 @@
package com.rosetta.messenger.data
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* 📝 Менеджер черновиков сообщений (как в Telegram)
* Хранит текст из инпута для каждого диалога.
* При возврате в список чатов показывает "Draft: текст" красным цветом.
*
* Использует SharedPreferences для персистентности между перезапусками.
* Ключ: "draft_{account}_{opponentKey}" -> текст черновика
*/
object DraftManager {
private const val PREFS_NAME = "rosetta_drafts"
private const val KEY_PREFIX = "draft_"
private var prefs: SharedPreferences? = null
// 🔥 Реактивный Map: opponentKey -> draftText
// Обновляется при каждом изменении черновика для мгновенного обновления UI списка чатов
private val _drafts = MutableStateFlow<Map<String, String>>(emptyMap())
val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
private var currentAccount: String = ""
/** Инициализация с контекстом приложения */
fun init(context: Context) {
if (prefs == null) {
prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
}
/** Установить текущий аккаунт и загрузить его черновики */
fun setAccount(account: String) {
if (currentAccount == account) return
currentAccount = account
loadDraftsFromPrefs()
}
/** Сохранить черновик для диалога */
fun saveDraft(opponentKey: String, text: String) {
if (currentAccount.isEmpty()) return
val trimmed = text.trim()
val currentDrafts = _drafts.value.toMutableMap()
if (trimmed.isEmpty()) {
// Удаляем черновик если текст пустой
currentDrafts.remove(opponentKey)
prefs?.edit()?.remove(prefKey(opponentKey))?.apply()
} else {
currentDrafts[opponentKey] = trimmed
prefs?.edit()?.putString(prefKey(opponentKey), trimmed)?.apply()
}
_drafts.value = currentDrafts
}
/** Получить черновик для диалога */
fun getDraft(opponentKey: String): String? {
return _drafts.value[opponentKey]
}
/** Очистить черновик для диалога (после отправки сообщения) */
fun clearDraft(opponentKey: String) {
if (currentAccount.isEmpty()) return
val currentDrafts = _drafts.value.toMutableMap()
currentDrafts.remove(opponentKey)
_drafts.value = currentDrafts
prefs?.edit()?.remove(prefKey(opponentKey))?.apply()
}
/** Очистить все черновики (при смене аккаунта / логауте) */
fun clearAll() {
_drafts.value = emptyMap()
}
/** Загрузить черновики текущего аккаунта из SharedPreferences */
private fun loadDraftsFromPrefs() {
val allPrefs = prefs?.all ?: return
val prefix = "${KEY_PREFIX}${currentAccount}_"
val loaded = mutableMapOf<String, String>()
allPrefs.forEach { (key, value) ->
if (key.startsWith(prefix) && value is String && value.isNotEmpty()) {
val opponentKey = key.removePrefix(prefix)
loaded[opponentKey] = value
}
}
_drafts.value = loaded
}
private fun prefKey(opponentKey: String): String {
return "${KEY_PREFIX}${currentAccount}_${opponentKey}"
}
}

View File

@@ -100,6 +100,15 @@ class MessageRepository private constructor(private val context: Context) {
const val SYSTEM_SAFE_TITLE = "Safe" const val SYSTEM_SAFE_TITLE = "Safe"
const val SYSTEM_SAFE_USERNAME = "safe" const val SYSTEM_SAFE_USERNAME = "safe"
const val SYSTEM_UPDATES_PUBLIC_KEY = "0x000000000000000000000000000000000000000001"
const val SYSTEM_UPDATES_TITLE = "Rosetta Updates"
const val SYSTEM_UPDATES_USERNAME = "updates"
/** All system account public keys */
val SYSTEM_ACCOUNT_KEYS = setOf(SYSTEM_SAFE_PUBLIC_KEY, SYSTEM_UPDATES_PUBLIC_KEY)
fun isSystemAccount(publicKey: String): Boolean = publicKey in SYSTEM_ACCOUNT_KEYS
// 🔥 In-memory кэш обработанных messageId для предотвращения дубликатов // 🔥 In-memory кэш обработанных messageId для предотвращения дубликатов
// LRU кэш с ограничением 1000 элементов - защита от race conditions // LRU кэш с ограничением 1000 элементов - защита от race conditions
private val processedMessageIds = private val processedMessageIds =
@@ -221,6 +230,80 @@ class MessageRepository private constructor(private val context: Context) {
_newMessageEvents.tryEmit(dialogKey) _newMessageEvents.tryEmit(dialogKey)
} }
/** Send a system message from "Rosetta Updates" account */
suspend fun addUpdateSystemMessage(messageText: String) {
val account = currentAccount ?: return
val privateKey = currentPrivateKey ?: return
val encryptedPlainMessage =
try {
CryptoManager.encryptWithPassword(messageText, privateKey)
} catch (_: Exception) {
return
}
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val dialogKey = getDialogKey(SYSTEM_UPDATES_PUBLIC_KEY)
val inserted =
messageDao.insertMessage(
MessageEntity(
account = account,
fromPublicKey = SYSTEM_UPDATES_PUBLIC_KEY,
toPublicKey = account,
content = "",
timestamp = timestamp,
chachaKey = "",
read = 0,
fromMe = 0,
delivered = DeliveryStatus.DELIVERED.value,
messageId = messageId,
plainMessage = encryptedPlainMessage,
attachments = "[]",
dialogKey = dialogKey
)
)
if (inserted == -1L) return
val existing = dialogDao.getDialog(account, SYSTEM_UPDATES_PUBLIC_KEY)
dialogDao.insertDialog(
DialogEntity(
id = existing?.id ?: 0,
account = account,
opponentKey = SYSTEM_UPDATES_PUBLIC_KEY,
opponentTitle = existing?.opponentTitle?.ifBlank { SYSTEM_UPDATES_TITLE } ?: SYSTEM_UPDATES_TITLE,
opponentUsername =
existing?.opponentUsername?.ifBlank { SYSTEM_UPDATES_USERNAME }
?: SYSTEM_UPDATES_USERNAME,
isOnline = existing?.isOnline ?: 0,
lastSeen = existing?.lastSeen ?: 0,
verified = maxOf(existing?.verified ?: 0, 1),
iHaveSent = 1
)
)
dialogDao.updateDialogFromMessages(account, SYSTEM_UPDATES_PUBLIC_KEY)
_newMessageEvents.tryEmit(dialogKey)
}
/**
* Check if app version changed and send update message from "Rosetta Updates"
* Called after account initialization, like desktop useUpdateMessage hook
*/
suspend fun checkAndSendVersionUpdateMessage() {
val account = currentAccount ?: return
val prefs = context.getSharedPreferences("rosetta_system_${account}", Context.MODE_PRIVATE)
val lastNoticeVersion = prefs.getString("lastNoticeVersion", "") ?: ""
val currentVersion = com.rosetta.messenger.BuildConfig.VERSION_NAME
if (lastNoticeVersion != currentVersion) {
addUpdateSystemMessage(ReleaseNotes.getNotice(currentVersion))
prefs.edit().putString("lastNoticeVersion", currentVersion).apply()
}
}
/** Инициализация с текущим аккаунтом */ /** Инициализация с текущим аккаунтом */
fun initialize(publicKey: String, privateKey: String) { fun initialize(publicKey: String, privateKey: String) {
val start = System.currentTimeMillis() val start = System.currentTimeMillis()

View File

@@ -40,6 +40,7 @@ class PreferencesManager(private val context: Context) {
val AUTO_DOWNLOAD_PHOTOS = booleanPreferencesKey("auto_download_photos") val AUTO_DOWNLOAD_PHOTOS = booleanPreferencesKey("auto_download_photos")
val AUTO_DOWNLOAD_VIDEOS = booleanPreferencesKey("auto_download_videos") val AUTO_DOWNLOAD_VIDEOS = booleanPreferencesKey("auto_download_videos")
val AUTO_DOWNLOAD_FILES = booleanPreferencesKey("auto_download_files") val AUTO_DOWNLOAD_FILES = booleanPreferencesKey("auto_download_files")
val CAMERA_FLASH_MODE = intPreferencesKey("camera_flash_mode") // 0=off, 1=auto, 2=on
// Privacy // Privacy
val SHOW_ONLINE_STATUS = booleanPreferencesKey("show_online_status") val SHOW_ONLINE_STATUS = booleanPreferencesKey("show_online_status")
@@ -158,6 +159,11 @@ class PreferencesManager(private val context: Context) {
val autoDownloadFiles: Flow<Boolean> = val autoDownloadFiles: Flow<Boolean> =
context.dataStore.data.map { preferences -> preferences[AUTO_DOWNLOAD_FILES] ?: false } context.dataStore.data.map { preferences -> preferences[AUTO_DOWNLOAD_FILES] ?: false }
val cameraFlashMode: Flow<Int> =
context.dataStore.data.map { preferences ->
normalizeCameraFlashMode(preferences[CAMERA_FLASH_MODE])
}
suspend fun setMessageTextSize(value: Int) { suspend fun setMessageTextSize(value: Int) {
context.dataStore.edit { preferences -> preferences[MESSAGE_TEXT_SIZE] = value } context.dataStore.edit { preferences -> preferences[MESSAGE_TEXT_SIZE] = value }
} }
@@ -178,6 +184,23 @@ class PreferencesManager(private val context: Context) {
context.dataStore.edit { preferences -> preferences[AUTO_DOWNLOAD_FILES] = value } context.dataStore.edit { preferences -> preferences[AUTO_DOWNLOAD_FILES] = value }
} }
suspend fun getCameraFlashMode(): Int {
return normalizeCameraFlashMode(context.dataStore.data.first()[CAMERA_FLASH_MODE])
}
suspend fun setCameraFlashMode(value: Int) {
context.dataStore.edit { preferences ->
preferences[CAMERA_FLASH_MODE] = normalizeCameraFlashMode(value)
}
}
private fun normalizeCameraFlashMode(value: Int?): Int {
return when (value) {
0, 1, 2 -> value
else -> 1
}
}
// ═════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════
// 🔐 PRIVACY // 🔐 PRIVACY
// ═════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════

View File

@@ -0,0 +1,31 @@
package com.rosetta.messenger.data
/**
* Release notes for "Rosetta Updates" system messages.
*
* When releasing a new version, update [RELEASE_NOTICE] below.
* The text will be sent once to each user after they update the app.
*/
object ReleaseNotes {
/**
* Current release notice shown to users after update.
* [VERSION_PLACEHOLDER] will be replaced with the actual version from BuildConfig.
*/
const val VERSION_PLACEHOLDER = "{version}"
val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER
- New attachment panel with tabs (photos, files, avatar)
- Message drafts — text is saved when leaving a chat
- Circular reveal animation for theme switching
- Photo albums in media picker
- Camera flash mode is now saved between sessions
- Swipe-back gesture fixes
- UI and performance improvements
""".trimIndent()
fun getNotice(version: String): String =
RELEASE_NOTICE.replace(VERSION_PLACEHOLDER, version)
}

View File

@@ -138,6 +138,10 @@ object ProtocolManager {
resyncRequiredAfterAccountInit = false resyncRequiredAfterAccountInit = false
requestSynchronize() requestSynchronize()
} }
// Send "Rosetta Updates" message on version change (like desktop useUpdateMessage)
scope.launch {
messageRepository?.checkAndSendVersionUpdateMessage()
}
} }
/** /**

View File

@@ -187,8 +187,11 @@ class AvatarRepository(
// Удаляем из БД // Удаляем из БД
avatarDao.deleteAllAvatars(currentPublicKey) avatarDao.deleteAllAvatars(currentPublicKey)
// Очищаем memory cache + отменяем Job // Важно: не удаляем cache entry и не отменяем Job здесь.
memoryCache.remove(currentPublicKey)?.job?.cancel() // Иначе уже подписанные composable продолжают слушать "мертвый" flow
// со старым значением до следующей рекомпозиции.
// Сразу пушим пустой список в существующий flow, чтобы UI обновился мгновенно.
memoryCache[currentPublicKey]?.flow?.value = emptyList()
} catch (e: Exception) { } catch (e: Exception) {
throw e throw e

View File

@@ -25,6 +25,7 @@ fun AuthFlow(
hasExistingAccount: Boolean, hasExistingAccount: Boolean,
accounts: List<AccountInfo> = emptyList(), accounts: List<AccountInfo> = emptyList(),
accountManager: AccountManager, accountManager: AccountManager,
startInCreateMode: Boolean = false,
onAuthComplete: (DecryptedAccount?) -> Unit, onAuthComplete: (DecryptedAccount?) -> Unit,
onLogout: () -> Unit = {} onLogout: () -> Unit = {}
) { ) {
@@ -42,6 +43,7 @@ fun AuthFlow(
var currentScreen by remember { var currentScreen by remember {
mutableStateOf( mutableStateOf(
when { when {
startInCreateMode -> AuthScreen.WELCOME
hasExistingAccount -> AuthScreen.UNLOCK hasExistingAccount -> AuthScreen.UNLOCK
else -> AuthScreen.WELCOME else -> AuthScreen.WELCOME
} }
@@ -56,6 +58,13 @@ fun AuthFlow(
} }
var showCreateModal by remember { mutableStateOf(false) } var showCreateModal by remember { mutableStateOf(false) }
var isImportMode by remember { mutableStateOf(false) } var isImportMode by remember { mutableStateOf(false) }
// If parent requests create mode while AuthFlow is alive, jump to Welcome/Create path.
LaunchedEffect(startInCreateMode) {
if (startInCreateMode && currentScreen == AuthScreen.UNLOCK) {
currentScreen = AuthScreen.WELCOME
}
}
// Handle system back button // Handle system back button
BackHandler(enabled = currentScreen != AuthScreen.UNLOCK && !(currentScreen == AuthScreen.WELCOME && !hasExistingAccount)) { BackHandler(enabled = currentScreen != AuthScreen.UNLOCK && !(currentScreen == AuthScreen.WELCOME && !hasExistingAccount)) {

View File

@@ -72,6 +72,7 @@ import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser 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.components.* import com.rosetta.messenger.ui.chats.components.*
import com.rosetta.messenger.ui.chats.components.ImageEditorScreen import com.rosetta.messenger.ui.chats.components.ImageEditorScreen
import com.rosetta.messenger.ui.chats.components.InAppCameraScreen import com.rosetta.messenger.ui.chats.components.InAppCameraScreen
@@ -489,7 +490,10 @@ fun ChatDetailScreen(
} }
// Динамический subtitle: typing > online > offline // Динамический subtitle: typing > online > offline
val isSystemAccount = user.publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY val isSystemAccount = MessageRepository.isSystemAccount(user.publicKey)
val isRosettaOfficial = user.title.equals("Rosetta", ignoreCase = true) ||
user.username.equals("rosetta", ignoreCase = true) ||
isSystemAccount
val chatSubtitle = val chatSubtitle =
when { when {
isSavedMessages -> "Notes" isSavedMessages -> "Notes"
@@ -1044,8 +1048,8 @@ fun ChatDetailScreen(
.Ellipsis .Ellipsis
) )
if (!isSavedMessages && if (!isSavedMessages &&
user.verified > (user.verified >
0 0 || isRosettaOfficial)
) { ) {
Spacer( Spacer(
modifier = modifier =
@@ -1055,7 +1059,7 @@ fun ChatDetailScreen(
) )
VerifiedBadge( VerifiedBadge(
verified = verified =
user.verified, if (user.verified > 0) user.verified else 1,
size = size =
16, 16,
isDarkTheme = isDarkTheme =
@@ -1873,7 +1877,8 @@ fun ChatDetailScreen(
else else
16.dp 16.dp
), ),
reverseLayout = true reverseLayout = true,
verticalArrangement = Arrangement.Bottom
) { ) {
itemsIndexed( itemsIndexed(
messagesWithDates, messagesWithDates,
@@ -2127,57 +2132,98 @@ fun ChatDetailScreen(
} }
} // Конец Column внутри Scaffold content } // Конец Column внутри Scaffold content
// 📎 Media Picker INLINE OVERLAY (Telegram-style gallery над клавиатурой) // 📎 Media Picker — new tab-based ChatAttachAlert (Telegram-style)
// Теперь это НЕ Dialog, а обычный composable внутри того же layout! // Feature flag: set USE_NEW_ATTACH_ALERT to false to use old MediaPickerBottomSheet
MediaPickerBottomSheet( val USE_NEW_ATTACH_ALERT = true
isVisible = showMediaPicker, if (USE_NEW_ATTACH_ALERT) {
onDismiss = { showMediaPicker = false }, ChatAttachAlert(
isDarkTheme = isDarkTheme, isVisible = showMediaPicker,
currentUserPublicKey = currentUserPublicKey, onDismiss = { showMediaPicker = false },
onMediaSelected = { selectedMedia, caption -> isDarkTheme = isDarkTheme,
// 📸 Отправляем фото напрямую с caption currentUserPublicKey = currentUserPublicKey,
val imageUris = onMediaSelected = { selectedMedia, caption ->
selectedMedia.filter { !it.isVideo }.map { it.uri } val imageUris =
selectedMedia.filter { !it.isVideo }.map { it.uri }
if (imageUris.isNotEmpty()) { if (imageUris.isNotEmpty()) {
showMediaPicker = false
inputFocusTrigger++
viewModel.sendImageGroupFromUris(imageUris, caption)
}
},
onMediaSelectedWithCaption = { mediaItem, caption ->
showMediaPicker = false showMediaPicker = false
inputFocusTrigger++ inputFocusTrigger++
viewModel.sendImageGroupFromUris(imageUris, caption) viewModel.sendImageFromUri(mediaItem.uri, caption)
} },
}, onOpenCamera = {
onMediaSelectedWithCaption = { mediaItem, caption -> val imm =
// 📸 Отправляем фото с caption напрямую context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
showMediaPicker = false imm.hideSoftInputFromWindow(view.windowToken, 0)
inputFocusTrigger++ window?.let { win ->
viewModel.sendImageFromUri(mediaItem.uri, caption) androidx.core.view.WindowCompat.getInsetsController(win, view)
}, .hide(androidx.core.view.WindowInsetsCompat.Type.ime())
onOpenCamera = { }
// 📷 Открываем встроенную камеру (без системного превью!) view.findFocus()?.clearFocus()
val imm = (context as? Activity)?.currentFocus?.clearFocus()
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager keyboardController?.hide()
imm.hideSoftInputFromWindow(view.windowToken, 0) focusManager.clearFocus(force = true)
window?.let { win -> showEmojiPicker = false
androidx.core.view.WindowCompat.getInsetsController(win, view) showMediaPicker = false
.hide(androidx.core.view.WindowInsetsCompat.Type.ime()) showInAppCamera = true
} },
view.findFocus()?.clearFocus() onOpenFilePicker = {
(context as? Activity)?.currentFocus?.clearFocus() filePickerLauncher.launch("*/*")
keyboardController?.hide() },
focusManager.clearFocus(force = true) onAvatarClick = {
showEmojiPicker = false viewModel.sendAvatarMessage()
showMediaPicker = false },
showInAppCamera = true recipientName = user.title
}, )
onOpenFilePicker = { } else {
// 📄 Открываем файловый пикер MediaPickerBottomSheet(
filePickerLauncher.launch("*/*") isVisible = showMediaPicker,
}, onDismiss = { showMediaPicker = false },
onAvatarClick = { isDarkTheme = isDarkTheme,
// 👤 Отправляем свой аватар (как в desktop) currentUserPublicKey = currentUserPublicKey,
viewModel.sendAvatarMessage() onMediaSelected = { selectedMedia, caption ->
}, val imageUris =
recipientName = user.title selectedMedia.filter { !it.isVideo }.map { it.uri }
) if (imageUris.isNotEmpty()) {
showMediaPicker = false
inputFocusTrigger++
viewModel.sendImageGroupFromUris(imageUris, caption)
}
},
onMediaSelectedWithCaption = { mediaItem, caption ->
showMediaPicker = false
inputFocusTrigger++
viewModel.sendImageFromUri(mediaItem.uri, caption)
},
onOpenCamera = {
val imm =
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
window?.let { win ->
androidx.core.view.WindowCompat.getInsetsController(win, view)
.hide(androidx.core.view.WindowInsetsCompat.Type.ime())
}
view.findFocus()?.clearFocus()
(context as? Activity)?.currentFocus?.clearFocus()
keyboardController?.hide()
focusManager.clearFocus(force = true)
showEmojiPicker = false
showMediaPicker = false
showInAppCamera = true
},
onOpenFilePicker = {
filePickerLauncher.launch("*/*")
},
onAvatarClick = {
viewModel.sendAvatarMessage()
},
recipientName = user.title
)
}
} // Закрытие Box wrapper для Scaffold content } // Закрытие Box wrapper для Scaffold content
} // Закрытие Box } // Закрытие Box

View File

@@ -574,6 +574,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога
isDialogActive = true // 🔥 Диалог активен! isDialogActive = true // 🔥 Диалог активен!
// 📝 Восстанавливаем черновик для этого диалога (draft, как в Telegram)
val draft = com.rosetta.messenger.data.DraftManager.getDraft(publicKey)
_inputText.value = draft ?: ""
// 📨 Применяем Forward сообщения СРАЗУ после сброса // 📨 Применяем Forward сообщения СРАЗУ после сброса
if (hasForward) { if (hasForward) {
// Конвертируем ForwardMessage в ReplyMessage // Конвертируем ForwardMessage в ReplyMessage
@@ -1598,6 +1602,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/** Обновить текст ввода */ /** Обновить текст ввода */
fun updateInputText(text: String) { fun updateInputText(text: String) {
_inputText.value = text _inputText.value = text
// 📝 Сохраняем черновик при каждом изменении текста (draft, как в Telegram)
opponentKey?.let { key ->
com.rosetta.messenger.data.DraftManager.saveDraft(key, text)
}
} }
/** /**
@@ -1850,7 +1858,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
addMessageSafely(optimisticMessage) addMessageSafely(optimisticMessage)
_inputText.value = "" _inputText.value = ""
// 🔥 Очищаем reply после отправки - данные сохраняются в displayReplyMessages для анимации // <EFBFBD> Очищаем черновик после отправки
opponentKey?.let { com.rosetta.messenger.data.DraftManager.clearDraft(it) }
// <20>🔥 Очищаем reply после отправки - данные сохраняются в displayReplyMessages для анимации
clearReplyMessages() clearReplyMessages()
// Кэшируем текст // Кэшируем текст

View File

@@ -21,8 +21,16 @@ 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.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.BlendMode
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.CompositingStrategy
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
@@ -30,16 +38,21 @@ import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
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.IntSize
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.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.core.view.drawToBitmap
import com.airbnb.lottie.compose.* import com.airbnb.lottie.compose.*
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.BuildConfig import com.rosetta.messenger.BuildConfig
@@ -70,6 +83,8 @@ import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlin.math.hypot
import kotlin.math.roundToInt
@Immutable @Immutable
data class Chat( data class Chat(
@@ -168,6 +183,26 @@ fun getAvatarText(publicKey: String): String {
return publicKey.take(2).uppercase() return publicKey.take(2).uppercase()
} }
private val TELEGRAM_DIALOG_AVATAR_START = 10.dp
private val TELEGRAM_DIALOG_TEXT_START = 72.dp
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
private val TELEGRAM_DIALOG_ROW_HEIGHT = 72.dp
private val TELEGRAM_DIALOG_VERTICAL_PADDING = 9.dp
private val TELEGRAM_DIALOG_AVATAR_GAP =
TELEGRAM_DIALOG_TEXT_START - TELEGRAM_DIALOG_AVATAR_START - TELEGRAM_DIALOG_AVATAR_SIZE
private fun maxRevealRadius(center: Offset, bounds: IntSize): Float {
if (bounds.width <= 0 || bounds.height <= 0) return 0f
val width = bounds.width.toFloat()
val height = bounds.height.toFloat()
return maxOf(
hypot(center.x, center.y),
hypot(width - center.x, center.y),
hypot(center.x, height - center.y),
hypot(width - center.x, height - center.y)
)
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun ChatsListScreen( fun ChatsListScreen(
@@ -195,9 +230,9 @@ fun ChatsListScreen(
onTogglePin: (String) -> Unit = {}, onTogglePin: (String) -> Unit = {},
chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(), chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null, avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
onLogout: () -> Unit,
onAddAccount: () -> Unit = {}, onAddAccount: () -> Unit = {},
onSwitchAccount: (String) -> Unit = {} onSwitchAccount: (String) -> Unit = {},
onDeleteAccountFromSidebar: (String) -> Unit = {}
) { ) {
// Theme transition state // Theme transition state
var hasInitialized by remember { mutableStateOf(false) } var hasInitialized by remember { mutableStateOf(false) }
@@ -209,6 +244,67 @@ fun ChatsListScreen(
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val themeRevealRadius = remember { Animatable(0f) }
var rootSize by remember { mutableStateOf(IntSize.Zero) }
var themeToggleCenterInRoot by remember { mutableStateOf<Offset?>(null) }
var themeRevealActive by remember { mutableStateOf(false) }
var themeRevealToDark by remember { mutableStateOf(false) }
var themeRevealCenter by remember { mutableStateOf(Offset.Zero) }
var themeRevealSnapshot by remember { mutableStateOf<ImageBitmap?>(null) }
fun startThemeReveal() {
if (themeRevealActive) {
return
}
if (rootSize.width <= 0 || rootSize.height <= 0) {
onToggleTheme()
return
}
val center =
themeToggleCenterInRoot
?: Offset(rootSize.width * 0.85f, rootSize.height * 0.12f)
val snapshotBitmap = runCatching { view.drawToBitmap() }.getOrNull()
if (snapshotBitmap == null) {
onToggleTheme()
return
}
val toDark = !isDarkTheme
val maxRadius = maxRevealRadius(center, rootSize)
if (maxRadius <= 0f) {
onToggleTheme()
return
}
themeRevealActive = true
themeRevealToDark = toDark
themeRevealCenter = center
themeRevealSnapshot = snapshotBitmap.asImageBitmap()
scope.launch {
try {
if (toDark) {
themeRevealRadius.snapTo(0f)
} else {
themeRevealRadius.snapTo(maxRadius)
}
onToggleTheme()
withFrameNanos { }
themeRevealRadius.animateTo(
targetValue = if (toDark) maxRadius else 0f,
animationSpec =
tween(
durationMillis = 400,
easing = CubicBezierEasing(0.45f, 0.05f, 0.55f, 0.95f)
)
)
} finally {
themeRevealSnapshot = null
themeRevealActive = false
}
}
}
// 🔥 ВСЕГДА закрываем клавиатуру при появлении ChatsListScreen // 🔥 ВСЕГДА закрываем клавиатуру при появлении ChatsListScreen
// Используем DisposableEffect чтобы срабатывало при каждом появлении экрана // Используем DisposableEffect чтобы срабатывало при каждом появлении экрана
@@ -336,6 +432,7 @@ fun ChatsListScreen(
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) } var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) } var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) }
var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) } var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) }
var accountToDelete by remember { mutableStateOf<EncryptedAccount?>(null) }
var deviceResolveRequest by var deviceResolveRequest by
remember { remember {
mutableStateOf<Pair<DeviceEntry, DeviceResolveAction>?>(null) mutableStateOf<Pair<DeviceEntry, DeviceResolveAction>?>(null)
@@ -520,6 +617,7 @@ fun ChatsListScreen(
Box( Box(
modifier = modifier =
Modifier.fillMaxSize() Modifier.fillMaxSize()
.onSizeChanged { rootSize = it }
.background(backgroundColor) .background(backgroundColor)
.navigationBarsPadding() .navigationBarsPadding()
) { ) {
@@ -594,6 +692,27 @@ fun ChatsListScreen(
Color.White Color.White
.copy(alpha = 0.2f) .copy(alpha = 0.2f)
) )
.combinedClickable(
onClick = {
scope.launch {
accountsSectionExpanded = false
drawerState.close()
kotlinx.coroutines.delay(150)
onProfileClick()
}
},
onLongClick = {
hapticFeedback.performHapticFeedback(
HapticFeedbackType.LongPress
)
accountToDelete =
allAccounts
.firstOrNull {
it.publicKey ==
accountPublicKey
}
}
)
.padding(3.dp), .padding(3.dp),
contentAlignment = contentAlignment =
Alignment.Center Alignment.Center
@@ -616,8 +735,24 @@ fun ChatsListScreen(
// Theme toggle icon // Theme toggle icon
IconButton( IconButton(
onClick = { onToggleTheme() }, onClick = { startThemeReveal() },
modifier = Modifier.size(48.dp) modifier =
Modifier.size(48.dp)
.onGloballyPositioned {
val pos =
it.positionInRoot()
themeToggleCenterInRoot =
Offset(
x =
pos.x +
it.size.width /
2f,
y =
pos.y +
it.size.height /
2f
)
}
) { ) {
Icon( Icon(
painter = painterResource( painter = painterResource(
@@ -664,7 +799,8 @@ fun ChatsListScreen(
) )
VerifiedBadge( VerifiedBadge(
verified = 1, verified = 1,
size = 15 size = 15,
badgeTint = Color.White
) )
} }
} }
@@ -726,16 +862,24 @@ fun ChatsListScreen(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.height(48.dp) .height(48.dp)
.clickable { .combinedClickable(
if (!isCurrentAccount) { onClick = {
scope.launch { if (!isCurrentAccount) {
accountsSectionExpanded = false scope.launch {
drawerState.close() accountsSectionExpanded = false
kotlinx.coroutines.delay(150) drawerState.close()
onSwitchAccount(account.publicKey) kotlinx.coroutines.delay(150)
onSwitchAccount(account.publicKey)
}
} }
},
onLongClick = {
hapticFeedback.performHapticFeedback(
HapticFeedbackType.LongPress
)
accountToDelete = account
} }
} )
.padding(start = 14.dp, end = 16.dp), .padding(start = 14.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -857,8 +1001,8 @@ fun ChatsListScreen(
else Color(0xFF889198) else Color(0xFF889198)
val menuTextColor = val menuTextColor =
if (isDarkTheme) Color(0xFFF4FFFFFF) if (isDarkTheme) Color(0xFFF4FFFFFF.toInt())
else Color(0xFF444444) else Color(0xFF444444.toInt())
val accentColor = if (isDarkTheme) PrimaryBlueDark else PrimaryBlue val accentColor = if (isDarkTheme) PrimaryBlueDark else PrimaryBlue
@@ -933,7 +1077,7 @@ fun ChatsListScreen(
} }
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 🚪 FOOTER - Logout & Version // FOOTER - Version
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Divider( Divider(
@@ -944,22 +1088,6 @@ fun ChatsListScreen(
thickness = 0.5.dp thickness = 0.5.dp
) )
// Logout
DrawerMenuItemEnhanced(
painter = TelegramIcons.Leave,
text = "Log Out",
iconColor = Color(0xFFFF4444),
textColor = Color(0xFFFF4444),
onClick = {
scope.launch {
drawerState.close()
kotlinx.coroutines
.delay(150)
onLogout()
}
}
)
// Version info // Version info
Box( Box(
modifier = modifier =
@@ -1667,16 +1795,24 @@ fun ChatsListScreen(
} }
items( items(
currentDialogs, items = currentDialogs,
key = { it.opponentKey }, key = { dialog ->
contentType = { "dialog" } dialog.opponentKey
},
contentType = { _ ->
"dialog"
}
) { dialog -> ) { dialog ->
val isSavedMessages = val isSavedMessages =
dialog.opponentKey == dialog.opponentKey ==
accountPublicKey accountPublicKey
val isPinnedDialog =
pinnedChats
.contains(
dialog.opponentKey
)
val isSystemSafeDialog = val isSystemSafeDialog =
dialog.opponentKey == MessageRepository.isSystemAccount(dialog.opponentKey)
MessageRepository.SYSTEM_SAFE_PUBLIC_KEY
val isBlocked = val isBlocked =
blockedUsers blockedUsers
.contains( .contains(
@@ -1693,6 +1829,34 @@ fun ChatsListScreen(
) )
} }
} }
val isSelectedDialog =
selectedChatKeys
.contains(
dialog.opponentKey
)
val dividerBackgroundColor =
when {
isSelectedDialog ->
if (isDarkTheme)
Color(
0xFF1A3A5C
)
else
Color(
0xFFD6EAFF
)
isPinnedDialog ->
if (isDarkTheme)
Color(
0xFF232323
)
else
Color(
0xFFE8E8ED
)
else ->
listBackgroundColor
}
Column( Column(
modifier = modifier =
@@ -1728,7 +1892,7 @@ fun ChatsListScreen(
swipedItemKey == swipedItemKey ==
dialog.opponentKey, dialog.opponentKey,
isSelected = isSelected =
selectedChatKeys.contains(dialog.opponentKey), isSelectedDialog,
onSwipeStarted = { onSwipeStarted = {
swipedItemKey = swipedItemKey =
dialog.opponentKey dialog.opponentKey
@@ -1780,10 +1944,7 @@ fun ChatsListScreen(
dialog dialog
}, },
isPinned = isPinned =
pinnedChats isPinnedDialog,
.contains(
dialog.opponentKey
),
swipeEnabled = swipeEnabled =
!isSystemSafeDialog, !isSystemSafeDialog,
onPin = { onPin = {
@@ -1796,16 +1957,45 @@ fun ChatsListScreen(
// 🔥 СЕПАРАТОР - // 🔥 СЕПАРАТОР -
// линия разделения // линия разделения
// между диалогами // между диалогами
Divider( Box(
modifier = modifier =
Modifier.padding( Modifier.fillMaxWidth()
start = .height(
84.dp 0.5.dp
), )
color = .background(
dividerColor, dividerBackgroundColor
thickness = )
0.5.dp ) {
Divider(
modifier =
Modifier.fillMaxWidth()
.padding(
start =
TELEGRAM_DIALOG_TEXT_START
),
color =
dividerColor,
thickness =
0.5.dp
)
}
}
}
// Footer hint
item(key = "chat_list_footer") {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "Tap on search to find new chats",
color = Color(0xFF8E8E93),
fontSize = 14.sp,
fontWeight = FontWeight.Normal
) )
} }
} }
@@ -1972,6 +2162,122 @@ fun ChatsListScreen(
) )
} }
if (themeRevealActive) {
val snapshot = themeRevealSnapshot
if (snapshot != null) {
Canvas(
modifier =
Modifier.fillMaxSize()
.graphicsLayer(
compositingStrategy =
CompositingStrategy.Offscreen
)
) {
val destinationSize =
IntSize(
width = size.width.roundToInt(),
height = size.height.roundToInt()
)
if (themeRevealToDark) {
drawImage(
image = snapshot,
srcOffset = IntOffset.Zero,
srcSize = IntSize(snapshot.width, snapshot.height),
dstOffset = IntOffset.Zero,
dstSize = destinationSize
)
drawCircle(
color = Color.Transparent,
radius = themeRevealRadius.value,
center = themeRevealCenter,
blendMode = BlendMode.Clear
)
} else {
val radius = themeRevealRadius.value
if (radius > 0f) {
val clipCirclePath =
Path().apply {
addOval(
Rect(
left = themeRevealCenter.x - radius,
top = themeRevealCenter.y - radius,
right = themeRevealCenter.x + radius,
bottom = themeRevealCenter.y + radius
)
)
}
clipPath(clipCirclePath) {
drawImage(
image = snapshot,
srcOffset = IntOffset.Zero,
srcSize =
IntSize(
snapshot.width,
snapshot.height
),
dstOffset = IntOffset.Zero,
dstSize = destinationSize
)
}
}
}
}
}
}
accountToDelete?.let { account ->
val isCurrentAccount = account.publicKey == accountPublicKey
val displayName =
resolveAccountDisplayName(
account.publicKey,
account.name,
account.username
)
AlertDialog(
onDismissRequest = { accountToDelete = null },
containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
title = {
Text(
text = "Delete Account",
fontWeight = FontWeight.Bold,
color = textColor
)
},
text = {
Text(
text =
if (isCurrentAccount) {
"Delete \"$displayName\" from this device? You will return to the main login screen."
} else {
"Delete \"$displayName\" from this device?"
},
color = secondaryTextColor
)
},
confirmButton = {
TextButton(
onClick = {
val publicKey = account.publicKey
accountToDelete = null
allAccounts =
allAccounts.filterNot {
it.publicKey == publicKey
}
scope.launch {
accountsSectionExpanded = false
drawerState.close()
onDeleteAccountFromSidebar(publicKey)
}
}
) { Text("Delete", color = Color(0xFFFF3B30)) }
},
dismissButton = {
TextButton(onClick = { accountToDelete = null }) {
Text("Cancel", color = PrimaryBlue)
}
}
)
}
} // Close Box } // Close Box
} }
@@ -2138,13 +2444,23 @@ private fun SkeletonDialogItem(shimmerColor: Color, isDarkTheme: Boolean) {
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), modifier =
Modifier.fillMaxWidth()
.padding(
horizontal = TELEGRAM_DIALOG_AVATAR_START,
vertical = TELEGRAM_DIALOG_VERTICAL_PADDING
),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Avatar placeholder // Avatar placeholder
Box(modifier = Modifier.size(56.dp).clip(CircleShape).background(shimmerColor)) Box(
modifier =
Modifier.size(TELEGRAM_DIALOG_AVATAR_SIZE)
.clip(CircleShape)
.background(shimmerColor)
)
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
// Name placeholder // Name placeholder
@@ -2180,7 +2496,7 @@ private fun SkeletonDialogItem(shimmerColor: Color, isDarkTheme: Boolean) {
Divider( Divider(
color = dividerColor, color = dividerColor,
thickness = 0.5.dp, thickness = 0.5.dp,
modifier = Modifier.padding(start = 84.dp) modifier = Modifier.padding(start = TELEGRAM_DIALOG_TEXT_START)
) )
} }
@@ -2290,21 +2606,24 @@ fun ChatItem(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(
horizontal = TELEGRAM_DIALOG_AVATAR_START,
vertical = TELEGRAM_DIALOG_VERTICAL_PADDING
),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Avatar with real image // Avatar with real image
AvatarImage( AvatarImage(
publicKey = chat.publicKey, publicKey = chat.publicKey,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
size = 56.dp, size = TELEGRAM_DIALOG_AVATAR_SIZE,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
showOnlineIndicator = true, showOnlineIndicator = true,
isOnline = chat.isOnline, isOnline = chat.isOnline,
displayName = chat.name // 🔥 Для инициалов displayName = chat.name // 🔥 Для инициалов
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row( Row(
@@ -2566,7 +2885,7 @@ fun SwipeableDialogItem(
val swipeWidthPx = with(density) { swipeWidthDp.toPx() } val swipeWidthPx = with(density) { swipeWidthDp.toPx() }
// Фиксированная высота элемента (как в DialogItem) // Фиксированная высота элемента (как в DialogItem)
val itemHeight = 80.dp val itemHeight = TELEGRAM_DIALOG_ROW_HEIGHT
// Close when another item starts swiping (like Telegram) // Close when another item starts swiping (like Telegram)
LaunchedEffect(isSwipedOpen) { LaunchedEffect(isSwipedOpen) {
@@ -2716,9 +3035,14 @@ fun SwipeableDialogItem(
} }
// 2. КОНТЕНТ - поверх кнопок, сдвигается при свайпе // 2. КОНТЕНТ - поверх кнопок, сдвигается при свайпе
// 🔥 rememberUpdatedState чтобы pointerInput всегда вызывал актуальные callbacks // 🔥 rememberUpdatedState чтобы pointerInput всегда вызывал актуальные значения
val currentOnClick by rememberUpdatedState(onClick) val currentOnClick by rememberUpdatedState(onClick)
val currentOnLongClick by rememberUpdatedState(onLongClick) val currentOnLongClick by rememberUpdatedState(onLongClick)
val currentIsDrawerOpen by rememberUpdatedState(isDrawerOpen)
val currentSwipeEnabled by rememberUpdatedState(swipeEnabled)
val currentSwipeWidthPx by rememberUpdatedState(swipeWidthPx)
val currentOnSwipeStarted by rememberUpdatedState(onSwipeStarted)
val currentOnSwipeClosed by rememberUpdatedState(onSwipeClosed)
Column( Column(
modifier = modifier =
Modifier.fillMaxSize() Modifier.fillMaxSize()
@@ -2736,7 +3060,7 @@ fun SwipeableDialogItem(
) )
// Don't handle swipes when drawer is open // Don't handle swipes when drawer is open
if (isDrawerOpen) return@awaitEachGesture if (currentIsDrawerOpen) return@awaitEachGesture
velocityTracker.resetTracking() velocityTracker.resetTracking()
var totalDragX = 0f var totalDragX = 0f
@@ -2747,6 +3071,7 @@ fun SwipeableDialogItem(
// Phase 1: Determine gesture type (tap / long-press / drag) // Phase 1: Determine gesture type (tap / long-press / drag)
// Wait up to longPressTimeout; if no up or slop → long press // Wait up to longPressTimeout; if no up or slop → long press
var gestureType = "unknown" var gestureType = "unknown"
var fingerIsUp = false
val result = withTimeoutOrNull(longPressTimeoutMs) { val result = withTimeoutOrNull(longPressTimeoutMs) {
while (true) { while (true) {
@@ -2780,8 +3105,31 @@ fun SwipeableDialogItem(
Unit Unit
} }
// Timeout → long press // Timeout → check if finger lifted during the race window
if (result == null) gestureType = "longpress" if (result == null) {
// Grace period: check if up event arrived just as timeout fired
val graceResult = withTimeoutOrNull(32L) {
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id }
if (change == null) {
gestureType = "cancelled"
fingerIsUp = true
return@withTimeoutOrNull Unit
}
if (change.changedToUpIgnoreConsumed()) {
change.consume()
gestureType = "tap"
fingerIsUp = true
return@withTimeoutOrNull Unit
}
// Still moving/holding — it's a real long press
break
}
Unit
}
if (gestureType == "unknown") gestureType = "longpress"
}
when (gestureType) { when (gestureType) {
"tap" -> { "tap" -> {
@@ -2811,10 +3159,10 @@ fun SwipeableDialogItem(
when { when {
// Horizontal left swipe — reveal action buttons // Horizontal left swipe — reveal action buttons
swipeEnabled && dominated && totalDragX < 0 -> { currentSwipeEnabled && dominated && totalDragX < 0 -> {
passedSlop = true passedSlop = true
claimed = true claimed = true
onSwipeStarted() currentOnSwipeStarted()
} }
// Horizontal right swipe with buttons open — close them // Horizontal right swipe with buttons open — close them
dominated && totalDragX > 0 && offsetX != 0f -> { dominated && totalDragX > 0 && offsetX != 0f -> {
@@ -2828,7 +3176,7 @@ fun SwipeableDialogItem(
else -> { else -> {
if (offsetX != 0f) { if (offsetX != 0f) {
offsetX = 0f offsetX = 0f
onSwipeClosed() currentOnSwipeClosed()
} }
return@awaitEachGesture return@awaitEachGesture
} }
@@ -2847,8 +3195,9 @@ fun SwipeableDialogItem(
if (change.changedToUpIgnoreConsumed()) break if (change.changedToUpIgnoreConsumed()) break
val delta = change.positionChange() val delta = change.positionChange()
val curSwipeWidthPx = currentSwipeWidthPx
val newOffset = offsetX + delta.x val newOffset = offsetX + delta.x
offsetX = newOffset.coerceIn(-swipeWidthPx, 0f) offsetX = newOffset.coerceIn(-curSwipeWidthPx, 0f)
velocityTracker.addPosition( velocityTracker.addPosition(
change.uptimeMillis, change.uptimeMillis,
change.position change.position
@@ -2858,6 +3207,7 @@ fun SwipeableDialogItem(
// Phase 3: Snap animation // Phase 3: Snap animation
if (claimed) { if (claimed) {
val curSwipeWidthPx = currentSwipeWidthPx
val velocity = val velocity =
velocityTracker velocityTracker
.calculateVelocity() .calculateVelocity()
@@ -2865,18 +3215,18 @@ fun SwipeableDialogItem(
when { when {
velocity > 150f -> { velocity > 150f -> {
offsetX = 0f offsetX = 0f
onSwipeClosed() currentOnSwipeClosed()
} }
velocity < -300f -> { velocity < -300f -> {
offsetX = -swipeWidthPx offsetX = -curSwipeWidthPx
} }
kotlin.math.abs(offsetX) > kotlin.math.abs(offsetX) >
swipeWidthPx / 2 -> { curSwipeWidthPx / 2 -> {
offsetX = -swipeWidthPx offsetX = -curSwipeWidthPx
} }
else -> { else -> {
offsetX = 0f offsetX = 0f
onSwipeClosed() currentOnSwipeClosed()
} }
} }
} }
@@ -2893,14 +3243,6 @@ fun SwipeableDialogItem(
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
onClick = null // Tap handled by parent pointerInput onClick = null // Tap handled by parent pointerInput
) )
// Сепаратор внутри контента
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
Divider(
modifier = Modifier.padding(start = 84.dp),
color = dividerColor,
thickness = 0.5.dp
)
} }
} }
} }
@@ -2998,11 +3340,14 @@ fun DialogItemContent(
if (onClick != null) Modifier.clickable(onClick = onClick) if (onClick != null) Modifier.clickable(onClick = onClick)
else Modifier else Modifier
) )
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(
horizontal = TELEGRAM_DIALOG_AVATAR_START,
vertical = TELEGRAM_DIALOG_VERTICAL_PADDING
),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Avatar container with online indicator // Avatar container with online indicator
Box(modifier = Modifier.size(56.dp)) { Box(modifier = Modifier.size(TELEGRAM_DIALOG_AVATAR_SIZE)) {
// Avatar // Avatar
if (dialog.isSavedMessages) { if (dialog.isSavedMessages) {
Box( Box(
@@ -3042,7 +3387,7 @@ fun DialogItemContent(
com.rosetta.messenger.ui.components.AvatarImage( com.rosetta.messenger.ui.components.AvatarImage(
publicKey = dialog.opponentKey, publicKey = dialog.opponentKey,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
size = 56.dp, size = TELEGRAM_DIALOG_AVATAR_SIZE,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
displayName = avatarDisplayName displayName = avatarDisplayName
) )
@@ -3069,7 +3414,7 @@ fun DialogItemContent(
} }
} }
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP))
// Name and last message // Name and last message
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
@@ -3090,9 +3435,12 @@ fun DialogItemContent(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
if (dialog.verified > 0) { val isRosettaOfficial = dialog.opponentTitle.equals("Rosetta", ignoreCase = true) ||
dialog.opponentUsername.equals("rosetta", ignoreCase = true) ||
MessageRepository.isSystemAccount(dialog.opponentKey)
if (dialog.verified > 0 || isRosettaOfficial) {
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge(verified = dialog.verified, size = 16) VerifiedBadge(verified = if (dialog.verified > 0) dialog.verified else 1, size = 16)
} }
// 🔒 Красная иконка замочка для заблокированных пользователей // 🔒 Красная иконка замочка для заблокированных пользователей
if (isBlocked) { if (isBlocked) {
@@ -3119,7 +3467,9 @@ fun DialogItemContent(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End horizontalArrangement = Arrangement.End
) { ) {
// 📁 Для Saved Messages ВСЕГДА показываем синие двойные // <EFBFBD> Скрываем статус доставки когда есть черновик (как в Telegram)
if (dialog.draftText.isNullOrEmpty()) {
// <20>📁 Для Saved Messages ВСЕГДА показываем синие двойные
// галочки (прочитано) // галочки (прочитано)
if (dialog.isSavedMessages) { if (dialog.isSavedMessages) {
Box( Box(
@@ -3270,6 +3620,7 @@ fun DialogItemContent(
} }
} }
} }
} // 📝 end if (draftText.isNullOrEmpty) — скрываем статус при наличии черновика
val formattedTime = val formattedTime =
remember(dialog.lastMessageTimestamp) { remember(dialog.lastMessageTimestamp) {
@@ -3297,8 +3648,29 @@ fun DialogItemContent(
// 🔥 Показываем typing индикатор или последнее сообщение // 🔥 Показываем typing индикатор или последнее сообщение
if (isTyping) { if (isTyping) {
TypingIndicatorSmall() TypingIndicatorSmall()
} else if (!dialog.draftText.isNullOrEmpty()) {
// 📝 Показываем черновик (как в Telegram)
Row(modifier = Modifier.weight(1f)) {
Text(
text = "Draft: ",
fontSize = 14.sp,
color = Color(0xFFFF3B30), // Красный как в Telegram
fontWeight = FontWeight.Normal,
maxLines = 1
)
AppleEmojiText(
text = dialog.draftText,
modifier = Modifier.weight(1f),
fontSize = 14.sp,
color = secondaryTextColor,
fontWeight = FontWeight.Normal,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = false
)
}
} else { } else {
// <EFBFBD> Определяем что показывать - attachment или текст // 📎 Определяем что показывать - attachment или текст
val displayText = val displayText =
when { when {
dialog.lastMessageAttachmentType == dialog.lastMessageAttachmentType ==
@@ -3497,13 +3869,16 @@ fun RequestsSection(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(
horizontal = TELEGRAM_DIALOG_AVATAR_START,
vertical = TELEGRAM_DIALOG_VERTICAL_PADDING
),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Иконка — круглый аватар как Archived Chats в Telegram // Иконка — круглый аватар как Archived Chats в Telegram
Box( Box(
modifier = modifier =
Modifier.size(56.dp) Modifier.size(TELEGRAM_DIALOG_AVATAR_SIZE)
.clip(CircleShape) .clip(CircleShape)
.background(iconBgColor), .background(iconBgColor),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -3516,7 +3891,7 @@ fun RequestsSection(
) )
} }
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
// Заголовок // Заголовок

View File

@@ -6,6 +6,7 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.DraftManager
import com.rosetta.messenger.database.BlacklistEntity import com.rosetta.messenger.database.BlacklistEntity
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.PacketOnlineSubscribe import com.rosetta.messenger.network.PacketOnlineSubscribe
@@ -40,7 +41,8 @@ data class DialogUiModel(
val lastMessageDelivered: Int = 0, // Статус доставки (0=WAITING, 1=DELIVERED, 2=ERROR) val lastMessageDelivered: Int = 0, // Статус доставки (0=WAITING, 1=DELIVERED, 2=ERROR)
val lastMessageRead: Int = 0, // Прочитано (0/1) val lastMessageRead: Int = 0, // Прочитано (0/1)
val lastMessageAttachmentType: String? = val lastMessageAttachmentType: String? =
null // 📎 Тип attachment: "Photo", "File", или null null, // 📎 Тип attachment: "Photo", "File", или null
val draftText: String? = null // 📝 Черновик сообщения (как в Telegram)
) )
/** /**
@@ -95,9 +97,17 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 🔥 НОВОЕ: Комбинированное состояние - обновляется атомарно! // 🔥 НОВОЕ: Комбинированное состояние - обновляется атомарно!
// 🎯 ИСПРАВЛЕНИЕ: debounce предотвращает мерцание при быстрых обновлениях // 🎯 ИСПРАВЛЕНИЕ: debounce предотвращает мерцание при быстрых обновлениях
// 📝 Комбинируем с drafts для показа черновиков в списке чатов
val chatsState: StateFlow<ChatsUiState> = val chatsState: StateFlow<ChatsUiState> =
combine(_dialogs, _requests, _requestsCount) { dialogs, requests, count -> combine(_dialogs, _requests, _requestsCount, DraftManager.drafts) { dialogs, requests, count, drafts ->
ChatsUiState(dialogs, requests, count) // 📝 Обогащаем диалоги черновиками
val dialogsWithDrafts = if (drafts.isEmpty()) dialogs else {
dialogs.map { dialog ->
val draft = drafts[dialog.opponentKey]
if (draft != null) dialog.copy(draftText = draft) else dialog
}
}
ChatsUiState(dialogsWithDrafts, requests, count)
} }
.distinctUntilChanged() // 🔥 Игнорируем дублирующиеся состояния .distinctUntilChanged() // 🔥 Игнорируем дублирующиеся состояния
.stateIn( .stateIn(
@@ -140,7 +150,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
currentAccount = publicKey currentAccount = publicKey
currentPrivateKey = privateKey currentPrivateKey = privateKey
// 🔥 Очищаем устаревшие данные от предыдущего аккаунта // <EFBFBD> Устанавливаем аккаунт для DraftManager (загрузит черновики из SharedPreferences)
DraftManager.setAccount(publicKey)
// <20>🔥 Очищаем устаревшие данные от предыдущего аккаунта
_dialogs.value = emptyList() _dialogs.value = emptyList()
_requests.value = emptyList() _requests.value = emptyList()
_requestsCount.value = 0 _requestsCount.value = 0

View File

@@ -0,0 +1,77 @@
package com.rosetta.messenger.ui.chats.attach
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
/**
* Avatar tab content for the attach alert.
* Shows a styled button to send the user's avatar.
*/
@Composable
internal fun AttachAlertAvatarLayout(
onAvatarClick: () -> Unit,
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = Color(0xFF8E8E93)
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(32.dp)
) {
Icon(
painter = TelegramIcons.Contact,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(72.dp)
)
Spacer(modifier = Modifier.height(20.dp))
Text(
text = "Send Avatar",
color = textColor,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Share your profile avatar with this contact",
color = secondaryTextColor,
fontSize = 14.sp,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(28.dp))
Button(
onClick = onAvatarClick,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.padding(horizontal = 24.dp)
) {
Text(
text = "Send Avatar",
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,77 @@
package com.rosetta.messenger.ui.chats.attach
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
/**
* File/Document tab content for the attach alert.
* Phase 1: Styled button that launches the system file picker.
*/
@Composable
internal fun AttachAlertFileLayout(
onOpenFilePicker: () -> Unit,
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = Color(0xFF8E8E93)
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(32.dp)
) {
Icon(
painter = TelegramIcons.File,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(72.dp)
)
Spacer(modifier = Modifier.height(20.dp))
Text(
text = "Send a File",
color = textColor,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Browse files on your device and share them in the chat",
color = secondaryTextColor,
fontSize = 14.sp,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(28.dp))
Button(
onClick = onOpenFilePicker,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.padding(horizontal = 24.dp)
) {
Text(
text = "Choose File",
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
}
}

View File

@@ -0,0 +1,102 @@
package com.rosetta.messenger.ui.chats.attach
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import android.content.pm.PackageManager
import com.rosetta.messenger.ui.chats.components.ThumbnailPosition
import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import androidx.compose.material3.Icon
/**
* Photo/Video tab content for the attach alert.
* Shows a 3-column grid with camera preview in the first cell,
* album dropdown, multi-select with numbered badges.
*/
@Composable
internal fun AttachAlertPhotoLayout(
state: AttachAlertUiState,
gridState: LazyGridState,
onCameraClick: () -> Unit,
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
onItemCheckClick: (MediaItem) -> Unit,
onItemLongClick: (MediaItem) -> Unit,
onRequestPermission: () -> Unit,
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = Color(0xFF8E8E93)
when {
!state.hasPermission -> {
PermissionRequestView(
isDarkTheme = isDarkTheme,
textColor = textColor,
secondaryTextColor = secondaryTextColor,
onRequestPermission = onRequestPermission
)
}
state.isLoading -> {
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = PrimaryBlue,
modifier = Modifier.size(48.dp)
)
}
}
state.visibleMediaItems.isEmpty() -> {
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
painter = TelegramIcons.Photos,
contentDescription = null,
tint = secondaryTextColor,
modifier = Modifier.size(64.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No photos or videos",
color = secondaryTextColor,
fontSize = 16.sp
)
}
}
}
else -> {
MediaGrid(
mediaItems = state.visibleMediaItems,
selectedItemOrder = state.selectedItemOrder,
showCameraItem = state.visibleAlbum?.isAllMedia != false,
gridState = gridState,
onCameraClick = onCameraClick,
onItemClick = onItemClick,
onItemCheckClick = onItemCheckClick,
onItemLongClick = onItemLongClick,
isDarkTheme = isDarkTheme,
modifier = modifier
)
}
}
}

View File

@@ -0,0 +1,69 @@
package com.rosetta.messenger.ui.chats.attach
import android.net.Uri
const val ALL_MEDIA_ALBUM_ID = 0L
/**
* Media item from gallery
*/
data class MediaItem(
val id: Long,
val uri: Uri,
val mimeType: String,
val duration: Long = 0, // For videos, in milliseconds
val dateModified: Long = 0,
val bucketId: Long = 0,
val bucketName: String = ""
) {
val isVideo: Boolean get() = mimeType.startsWith("video/")
}
data class MediaAlbum(
val id: Long,
val name: String,
val items: List<MediaItem>,
val isAllMedia: Boolean = false
)
data class MediaPickerData(
val items: List<MediaItem>,
val albums: List<MediaAlbum>
)
/**
* Tab types for the attach alert
*/
enum class AttachAlertTab {
PHOTO,
FILE,
AVATAR
}
/**
* UI state for the attach alert, managed by AttachAlertViewModel
*/
data class AttachAlertUiState(
val currentTab: AttachAlertTab = AttachAlertTab.PHOTO,
val mediaItems: List<MediaItem> = emptyList(),
val albums: List<MediaAlbum> = emptyList(),
val selectedAlbumId: Long = ALL_MEDIA_ALBUM_ID,
val selectedItemOrder: List<Long> = emptyList(),
val isLoading: Boolean = true,
val hasPermission: Boolean = false,
val captionText: String = "",
val editingItem: MediaItem? = null
) {
val selectedCount: Int get() = selectedItemOrder.size
val visibleAlbum: MediaAlbum?
get() = albums.firstOrNull { it.id == selectedAlbumId } ?: albums.firstOrNull()
val visibleMediaItems: List<MediaItem>
get() = visibleAlbum?.items ?: mediaItems
val canSwitchAlbums: Boolean get() = albums.size > 1
}
// ThumbnailPosition is defined in com.rosetta.messenger.ui.chats.components.ImageEditorScreen
// and reused via import. No duplicate needed here.

View File

@@ -0,0 +1,108 @@
package com.rosetta.messenger.ui.chats.attach
import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class AttachAlertViewModel(application: Application) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow(AttachAlertUiState())
val uiState: StateFlow<AttachAlertUiState> = _uiState.asStateFlow()
fun loadMedia(context: Context) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val data = loadMediaPickerData(context)
_uiState.update {
it.copy(
mediaItems = data.items,
albums = data.albums,
selectedAlbumId = data.albums.firstOrNull()?.id ?: ALL_MEDIA_ALBUM_ID,
isLoading = false
)
}
}
}
fun selectTab(tab: AttachAlertTab) {
_uiState.update { it.copy(currentTab = tab) }
}
fun selectAlbum(albumId: Long) {
_uiState.update { it.copy(selectedAlbumId = albumId) }
}
fun toggleSelection(itemId: Long, maxSelection: Int = 10) {
_uiState.update { state ->
val currentOrder = state.selectedItemOrder
val newOrder = if (currentOrder.contains(itemId)) {
currentOrder.filterNot { it == itemId }
} else if (currentOrder.size < maxSelection) {
currentOrder + itemId
} else {
currentOrder
}
state.copy(selectedItemOrder = newOrder)
}
}
fun clearSelection() {
_uiState.update { it.copy(selectedItemOrder = emptyList(), captionText = "") }
}
fun updateCaption(text: String) {
_uiState.update { it.copy(captionText = text) }
}
fun setPermissionGranted(granted: Boolean) {
_uiState.update { it.copy(hasPermission = granted) }
}
fun setEditingItem(item: MediaItem?) {
_uiState.update { it.copy(editingItem = item) }
}
fun resolveSelectedMedia(): List<MediaItem> {
val state = _uiState.value
if (state.selectedItemOrder.isEmpty()) return emptyList()
val byId = state.mediaItems.associateBy { it.id }
return state.selectedItemOrder.mapNotNull { byId[it] }
}
fun selectionHeaderText(): String {
val state = _uiState.value
val count = state.selectedCount
if (count == 0) return ""
val byId = state.mediaItems.associateBy { it.id }
val selected = state.selectedItemOrder.mapNotNull { byId[it] }
val hasPhotos = selected.any { !it.isVideo }
val hasVideos = selected.any { it.isVideo }
return when {
hasPhotos && hasVideos -> "$count media selected"
hasVideos -> if (count == 1) "$count video selected" else "$count videos selected"
else -> if (count == 1) "$count photo selected" else "$count photos selected"
}
}
fun reset() {
_uiState.value = AttachAlertUiState()
}
fun onShow() {
_uiState.update {
it.copy(
selectedItemOrder = emptyList(),
captionText = "",
selectedAlbumId = ALL_MEDIA_ALBUM_ID,
currentTab = AttachAlertTab.PHOTO,
editingItem = null
)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
package com.rosetta.messenger.ui.chats.attach
import android.content.ContentUris
import android.content.Context
import android.provider.MediaStore
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private const val TAG = "MediaRepository"
/**
* Loads media items from device gallery, grouped into albums.
* Mirrors Telegram's MediaController album loading logic:
* - "All media" album (bucketId=0) contains everything sorted by date
* - Real albums sorted: All media first, then by item count descending
*/
suspend fun loadMediaPickerData(context: Context): MediaPickerData = withContext(Dispatchers.IO) {
val items = mutableListOf<MediaItem>()
try {
// Query images with bucket info
val imageProjection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.MIME_TYPE,
MediaStore.Images.Media.DATE_MODIFIED,
MediaStore.Images.Media.BUCKET_ID,
MediaStore.Images.Media.BUCKET_DISPLAY_NAME
)
context.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
imageProjection,
null,
null,
"${MediaStore.Images.Media.DATE_MODIFIED} DESC"
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val mimeColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE)
val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED)
val bucketIdColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.BUCKET_ID)
val bucketNameColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.BUCKET_DISPLAY_NAME)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val mimeType = cursor.getString(mimeColumn) ?: "image/*"
val dateModified = cursor.getLong(dateColumn)
val bucketId = cursor.getLong(bucketIdColumn)
val bucketName = cursor.getString(bucketNameColumn) ?: "Unknown"
val uri = ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
id
)
items.add(
MediaItem(
id = id,
uri = uri,
mimeType = mimeType,
dateModified = dateModified,
bucketId = bucketId,
bucketName = bucketName
)
)
}
}
// Query videos with bucket info
val videoProjection = arrayOf(
MediaStore.Video.Media._ID,
MediaStore.Video.Media.MIME_TYPE,
MediaStore.Video.Media.DURATION,
MediaStore.Video.Media.DATE_MODIFIED,
MediaStore.Video.Media.BUCKET_ID,
MediaStore.Video.Media.BUCKET_DISPLAY_NAME
)
context.contentResolver.query(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
videoProjection,
null,
null,
"${MediaStore.Video.Media.DATE_MODIFIED} DESC"
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
val mimeColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.MIME_TYPE)
val durationColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION)
val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATE_MODIFIED)
val bucketIdColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.BUCKET_ID)
val bucketNameColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.BUCKET_DISPLAY_NAME)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val mimeType = cursor.getString(mimeColumn) ?: "video/*"
val duration = cursor.getLong(durationColumn)
val dateModified = cursor.getLong(dateColumn)
val bucketId = cursor.getLong(bucketIdColumn)
val bucketName = cursor.getString(bucketNameColumn) ?: "Unknown"
val uri = ContentUris.withAppendedId(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
id
)
// Use negative id to avoid collision with images
items.add(
MediaItem(
id = -id,
uri = uri,
mimeType = mimeType,
duration = duration,
dateModified = dateModified,
bucketId = bucketId,
bucketName = bucketName
)
)
}
}
// Sort all items by date
items.sortByDescending { it.dateModified }
} catch (e: Exception) {
Log.e(TAG, "Failed to load media", e)
}
// Build albums (like Telegram's updateAlbumsDropDown)
val allMediaAlbum = MediaAlbum(
id = ALL_MEDIA_ALBUM_ID,
name = "All media",
items = items,
isAllMedia = true
)
val bucketAlbums = items
.groupBy { it.bucketId }
.map { (bucketId, bucketItems) ->
MediaAlbum(
id = bucketId,
name = bucketItems.first().bucketName,
items = bucketItems.sortedByDescending { it.dateModified }
)
}
.sortedByDescending { it.items.size } // Largest albums first, like Telegram
val albums = listOf(allMediaAlbum) + bucketAlbums
MediaPickerData(items = items, albums = albums)
}

View File

@@ -1238,7 +1238,9 @@ fun ImageAttachment(
Icon( Icon(
painter = TelegramIcons.Done, painter = TelegramIcons.Done,
contentDescription = null, contentDescription = null,
tint = Color.White.copy(alpha = 0.7f), tint =
if (isDarkTheme) Color.White
else Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(14.dp) modifier = Modifier.size(14.dp)
) )
} }
@@ -1246,7 +1248,9 @@ fun ImageAttachment(
Icon( Icon(
painter = TelegramIcons.Done, painter = TelegramIcons.Done,
contentDescription = null, contentDescription = null,
tint = Color.White.copy(alpha = 0.7f), tint =
if (isDarkTheme) Color.White
else Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(14.dp) modifier = Modifier.size(14.dp)
) )
} }
@@ -1647,20 +1651,28 @@ fun FileAttachment(
Icon( Icon(
painter = TelegramIcons.Done, painter = TelegramIcons.Done,
contentDescription = null, contentDescription = null,
tint = Color.White.copy(alpha = 0.7f), tint =
if (isDarkTheme) Color.White
else Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(14.dp) modifier = Modifier.size(14.dp)
) )
} }
MessageStatus.DELIVERED, MessageStatus.READ -> { MessageStatus.DELIVERED -> {
Icon( Icon(
painter = TelegramIcons.Done, painter = TelegramIcons.Done,
contentDescription = null, contentDescription = null,
tint = tint =
if (messageStatus == MessageStatus.READ) { if (isDarkTheme) Color.White
Color(0xFF4FC3F7) else Color.White.copy(alpha = 0.7f),
} else { modifier = Modifier.size(14.dp)
Color.White.copy(alpha = 0.7f) )
}, }
MessageStatus.READ -> {
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint =
if (isDarkTheme) Color.White else Color(0xFF4FC3F7),
modifier = Modifier.size(14.dp) modifier = Modifier.size(14.dp)
) )
} }
@@ -2035,7 +2047,9 @@ fun AvatarAttachment(
Icon( Icon(
painter = TelegramIcons.Done, painter = TelegramIcons.Done,
contentDescription = "Sent", contentDescription = "Sent",
tint = Color.White.copy(alpha = 0.6f), tint =
if (isDarkTheme) Color.White
else Color.White.copy(alpha = 0.6f),
modifier = Modifier.size(14.dp) modifier = Modifier.size(14.dp)
) )
} }
@@ -2043,7 +2057,9 @@ fun AvatarAttachment(
Icon( Icon(
painter = TelegramIcons.Done, painter = TelegramIcons.Done,
contentDescription = "Delivered", contentDescription = "Delivered",
tint = Color.White.copy(alpha = 0.6f), tint =
if (isDarkTheme) Color.White
else Color.White.copy(alpha = 0.6f),
modifier = Modifier.size(14.dp) modifier = Modifier.size(14.dp)
) )
} }

View File

@@ -324,6 +324,10 @@ fun MessageBubble(
if (message.isOutgoing) Color.White.copy(alpha = 0.7f) if (message.isOutgoing) Color.White.copy(alpha = 0.7f)
else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
} }
val statusColor =
remember(message.isOutgoing, isDarkTheme, timeColor) {
if (message.isOutgoing) Color.White else timeColor
}
val isSafeSystemMessage = val isSafeSystemMessage =
isSystemSafeChat && isSystemSafeChat &&
@@ -817,7 +821,11 @@ fun MessageBubble(
status = status =
displayStatus, displayStatus,
timeColor = timeColor =
timeColor, statusColor,
isDarkTheme =
isDarkTheme,
isOutgoing =
message.isOutgoing,
timestamp = timestamp =
message.timestamp message.timestamp
.time, .time,
@@ -901,7 +909,11 @@ fun MessageBubble(
status = status =
displayStatus, displayStatus,
timeColor = timeColor =
timeColor, statusColor,
isDarkTheme =
isDarkTheme,
isOutgoing =
message.isOutgoing,
timestamp = timestamp =
message.timestamp message.timestamp
.time, .time,
@@ -978,7 +990,11 @@ fun MessageBubble(
status = status =
displayStatus, displayStatus,
timeColor = timeColor =
timeColor, statusColor,
isDarkTheme =
isDarkTheme,
isOutgoing =
message.isOutgoing,
timestamp = timestamp =
message.timestamp message.timestamp
.time, .time,
@@ -1078,6 +1094,8 @@ private fun buildSafeSystemAnnotatedText(text: String) = buildAnnotatedString {
fun AnimatedMessageStatus( fun AnimatedMessageStatus(
status: MessageStatus, status: MessageStatus,
timeColor: Color, timeColor: Color,
isDarkTheme: Boolean = false,
isOutgoing: Boolean = false,
timestamp: Long = 0L, timestamp: Long = 0L,
onRetry: () -> Unit = {}, onRetry: () -> Unit = {},
onDelete: () -> Unit = {} onDelete: () -> Unit = {}
@@ -1090,7 +1108,7 @@ fun AnimatedMessageStatus(
val targetColor = val targetColor =
when (effectiveStatus) { when (effectiveStatus) {
MessageStatus.READ -> Color(0xFF4FC3F7) MessageStatus.READ -> if (isDarkTheme || isOutgoing) Color.White else Color(0xFF4FC3F7)
MessageStatus.ERROR -> Color(0xFFE53935) MessageStatus.ERROR -> Color(0xFFE53935)
else -> timeColor else -> timeColor
} }
@@ -2126,6 +2144,7 @@ fun OtherProfileMenu(
onDismiss: () -> Unit, onDismiss: () -> Unit,
isDarkTheme: Boolean, isDarkTheme: Boolean,
isBlocked: Boolean, isBlocked: Boolean,
isSystemAccount: Boolean = false,
onBlockClick: () -> Unit, onBlockClick: () -> Unit,
onClearChatClick: () -> Unit onClearChatClick: () -> Unit
) { ) {
@@ -2153,13 +2172,15 @@ fun OtherProfileMenu(
dismissOnClickOutside = true dismissOnClickOutside = true
) )
) { ) {
ProfilePhotoMenuItem( if (!isSystemAccount) {
icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block, ProfilePhotoMenuItem(
text = if (isBlocked) "Unblock User" else "Block User", icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block,
onClick = onBlockClick, text = if (isBlocked) "Unblock User" else "Block User",
tintColor = iconColor, onClick = onBlockClick,
textColor = textColor tintColor = iconColor,
) textColor = textColor
)
}
ProfilePhotoMenuItem( ProfilePhotoMenuItem(
icon = TelegramIcons.Delete, icon = TelegramIcons.Delete,

View File

@@ -41,6 +41,7 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.zIndex
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
@@ -55,6 +56,8 @@ import androidx.compose.ui.text.font.FontWeight
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.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
@@ -144,6 +147,28 @@ fun ImageEditorScreen(
thumbnailPosition: ThumbnailPosition? = null, // Позиция для Telegram-style анимации thumbnailPosition: ThumbnailPosition? = null, // Позиция для Telegram-style анимации
skipEnterAnimation: Boolean = false // Из камеры — без fade, мгновенно skipEnterAnimation: Boolean = false // Из камеры — без fade, мгновенно
) { ) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
dismissOnBackPress = false, // BackHandler handles this
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
// Configure Dialog window for fullscreen edge-to-edge
val dialogView = LocalView.current
SideEffect {
val dialogWindow = (dialogView.parent as? androidx.compose.ui.window.DialogWindowProvider)?.window
?: (dialogView.context as? Activity)?.window
dialogWindow?.let { win ->
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
win.statusBarColor = android.graphics.Color.BLACK
win.navigationBarColor = android.graphics.Color.BLACK
win.setWindowAnimations(0)
}
}
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val view = LocalView.current val view = LocalView.current
@@ -186,7 +211,7 @@ fun ImageEditorScreen(
SystemBarsStyleUtils.capture(window, view) SystemBarsStyleUtils.capture(window, view)
} }
LaunchedEffect(window, view) { SideEffect {
SystemBarsStyleUtils.applyFullscreenDark(window, view) SystemBarsStyleUtils.applyFullscreenDark(window, view)
} }
@@ -201,7 +226,7 @@ fun ImageEditorScreen(
) )
} }
} }
// Функция для плавного закрытия // Функция для плавного закрытия
fun animatedDismiss() { fun animatedDismiss() {
if (isClosing) return if (isClosing) return
@@ -315,6 +340,9 @@ fun ImageEditorScreen(
var photoEditor by remember { mutableStateOf<PhotoEditor?>(null) } var photoEditor by remember { mutableStateOf<PhotoEditor?>(null) }
var photoEditorView by remember { mutableStateOf<PhotoEditorView?>(null) } var photoEditorView by remember { mutableStateOf<PhotoEditorView?>(null) }
// Track whether user made drawing edits (to skip PhotoEditor save when only cropping)
var hasDrawingEdits by remember { mutableStateOf(false) }
// UCrop launcher // UCrop launcher
val cropLauncher = rememberLauncherForActivityResult( val cropLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult() contract = ActivityResultContracts.StartActivityForResult()
@@ -386,18 +414,17 @@ fun ImageEditorScreen(
animatedBackgroundAlpha = progress animatedBackgroundAlpha = progress
} }
// Telegram behavior: photo stays fullscreen, only input moves with keyboard
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
// 🔥 Блокируем свайпы от SwipeBackContainer - на ImageEditor свайпы не должны работать .background(Color.Black)
.zIndex(100f)
.pointerInput(Unit) { .pointerInput(Unit) {
awaitEachGesture { awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false) val down = awaitFirstDown(requireUnconsumed = false)
down.consume() // Поглощаем все touch события down.consume()
} }
} }
.background(Color.Black)
) { ) {
Box( Box(
modifier = modifier =
@@ -438,9 +465,7 @@ fun ImageEditorScreen(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
// ═══════════════════════════════════════════════════════════ // TOP BAR
// 🎛️ TOP BAR - Solid black (Telegram style)
// ═══════════════════════════════════════════════════════════
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -639,7 +664,8 @@ fun ImageEditorScreen(
context = context, context = context,
photoEditor = photoEditor, photoEditor = photoEditor,
photoEditorView = photoEditorView, photoEditorView = photoEditorView,
imageUri = uriToSend imageUri = uriToSend,
hasDrawingEdits = hasDrawingEdits
) )
isSaving = false isSaving = false
@@ -735,6 +761,7 @@ fun ImageEditorScreen(
} }
} else { } else {
currentTool = EditorTool.DRAW currentTool = EditorTool.DRAW
hasDrawingEdits = true
isEraserActive = false isEraserActive = false
photoEditor?.setBrushDrawingMode(true) photoEditor?.setBrushDrawingMode(true)
photoEditor?.brushColor = selectedColor.toArgb() photoEditor?.brushColor = selectedColor.toArgb()
@@ -769,7 +796,8 @@ fun ImageEditorScreen(
context = context, context = context,
photoEditor = photoEditor, photoEditor = photoEditor,
photoEditorView = photoEditorView, photoEditorView = photoEditorView,
imageUri = currentImageUri imageUri = currentImageUri,
hasDrawingEdits = hasDrawingEdits
) )
isSaving = false isSaving = false
onSave(savedUri ?: currentImageUri) onSave(savedUri ?: currentImageUri)
@@ -783,6 +811,7 @@ fun ImageEditorScreen(
} }
} }
} }
} // Dialog
} }
/** /**
@@ -1220,12 +1249,12 @@ private suspend fun saveEditedImageOld(
} }
try { try {
val tempFile = File(context.cacheDir, "edited_${System.currentTimeMillis()}.png") val tempFile = File(context.cacheDir, "edited_${System.currentTimeMillis()}.jpg")
val saveSettings = SaveSettings.Builder() val saveSettings = SaveSettings.Builder()
.setClearViewsEnabled(false) .setClearViewsEnabled(false)
.setTransparencyEnabled(true) .setTransparencyEnabled(false)
.setCompressFormat(Bitmap.CompressFormat.PNG) .setCompressFormat(Bitmap.CompressFormat.JPEG)
.setCompressQuality(100) .setCompressQuality(95)
.build() .build()
val savedPath = suspendCancellableCoroutine<String?> { continuation -> val savedPath = suspendCancellableCoroutine<String?> { continuation ->
@@ -1265,8 +1294,13 @@ private suspend fun saveEditedImageSync(
context: Context, context: Context,
photoEditor: PhotoEditor?, photoEditor: PhotoEditor?,
photoEditorView: PhotoEditorView?, photoEditorView: PhotoEditorView?,
imageUri: Uri imageUri: Uri,
hasDrawingEdits: Boolean = false
): Uri? { ): Uri? {
// No drawing edits — return the source URI directly (clean crop from UCrop, no letterbox)
if (!hasDrawingEdits) {
return imageUri
}
return saveEditedImageSyncOld( return saveEditedImageSyncOld(
context = context, context = context,
photoEditor = photoEditor, photoEditor = photoEditor,
@@ -1291,14 +1325,14 @@ private suspend fun saveEditedImageSyncOld(
return try { return try {
val tempFile = File( val tempFile = File(
context.cacheDir, context.cacheDir,
"edited_${System.currentTimeMillis()}_${(0..9999).random()}.png" "edited_${System.currentTimeMillis()}_${(0..9999).random()}.jpg"
) )
val saveSettings = SaveSettings.Builder() val saveSettings = SaveSettings.Builder()
.setClearViewsEnabled(false) .setClearViewsEnabled(false)
.setTransparencyEnabled(true) .setTransparencyEnabled(false)
.setCompressFormat(Bitmap.CompressFormat.PNG) .setCompressFormat(Bitmap.CompressFormat.JPEG)
.setCompressQuality(100) .setCompressQuality(95)
.build() .build()
val savedPath = suspendCancellableCoroutine<String?> { continuation -> val savedPath = suspendCancellableCoroutine<String?> { continuation ->
@@ -1381,8 +1415,10 @@ private suspend fun removeLetterboxFromEditedImage(
return@runCatching editedUri return@runCatching editedUri
} }
// Safety guard against invalid/asymmetric crop. // Safety guard: only skip if crop is unreasonably small (< 5% of area)
if (cropWidth < editedWidth / 3 || cropHeight < editedHeight / 3) { if (cropWidth < 10 || cropHeight < 10 ||
(cropWidth.toLong() * cropHeight.toLong()) < (editedWidth.toLong() * editedHeight.toLong()) / 20
) {
editedBitmap.recycle() editedBitmap.recycle()
return@runCatching editedUri return@runCatching editedUri
} }
@@ -1400,10 +1436,10 @@ private suspend fun removeLetterboxFromEditedImage(
val normalizedFile = val normalizedFile =
File( File(
context.cacheDir, context.cacheDir,
"edited_normalized_${System.currentTimeMillis()}_${(0..9999).random()}.png" "edited_normalized_${System.currentTimeMillis()}_${(0..9999).random()}.jpg"
) )
normalizedFile.outputStream().use { out -> normalizedFile.outputStream().use { out ->
cropped.compress(Bitmap.CompressFormat.PNG, 100, out) cropped.compress(Bitmap.CompressFormat.JPEG, 95, out)
out.flush() out.flush()
} }
cropped.recycle() cropped.recycle()
@@ -1448,18 +1484,18 @@ private fun launchCrop(
launcher: androidx.activity.result.ActivityResultLauncher<Intent> launcher: androidx.activity.result.ActivityResultLauncher<Intent>
) { ) {
try { try {
val destinationFile = File(context.cacheDir, "cropped_${System.currentTimeMillis()}.png") val destinationFile = File(context.cacheDir, "cropped_${System.currentTimeMillis()}.jpg")
val destinationUri = Uri.fromFile(destinationFile) val destinationUri = Uri.fromFile(destinationFile)
val options = UCrop.Options().apply { val options = UCrop.Options().apply {
setCompressionFormat(Bitmap.CompressFormat.PNG) setCompressionFormat(Bitmap.CompressFormat.JPEG)
setCompressionQuality(100) setCompressionQuality(95)
// Dark theme // Dark theme
setToolbarColor(android.graphics.Color.BLACK) setToolbarColor(android.graphics.Color.BLACK)
setStatusBarColor(android.graphics.Color.BLACK) setStatusBarColor(android.graphics.Color.BLACK)
setActiveControlsWidgetColor(android.graphics.Color.parseColor("#007AFF")) setActiveControlsWidgetColor(android.graphics.Color.parseColor("#007AFF"))
setToolbarWidgetColor(android.graphics.Color.WHITE) setToolbarWidgetColor(android.graphics.Color.WHITE)
setRootViewBackgroundColor(android.graphics.Color.BLACK) setRootViewBackgroundColor(android.graphics.Color.WHITE)
setFreeStyleCropEnabled(true) setFreeStyleCropEnabled(true)
setShowCropGrid(true) setShowCropGrid(true)
setShowCropFrame(true) setShowCropFrame(true)
@@ -1488,6 +1524,27 @@ fun MultiImageEditorScreen(
isDarkTheme: Boolean = true, isDarkTheme: Boolean = true,
recipientName: String? = null // Имя получателя (как в Telegram) recipientName: String? = null // Имя получателя (как в Telegram)
) { ) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
val dialogView = LocalView.current
SideEffect {
val dialogWindow = (dialogView.parent as? androidx.compose.ui.window.DialogWindowProvider)?.window
?: (dialogView.context as? Activity)?.window
dialogWindow?.let { win ->
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
win.statusBarColor = android.graphics.Color.BLACK
win.navigationBarColor = android.graphics.Color.BLACK
win.setWindowAnimations(0)
}
}
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val view = LocalView.current val view = LocalView.current
@@ -1540,7 +1597,7 @@ fun MultiImageEditorScreen(
SystemBarsStyleUtils.capture(window, view) SystemBarsStyleUtils.capture(window, view)
} }
LaunchedEffect(window, view) { SideEffect {
SystemBarsStyleUtils.applyFullscreenDark(window, view) SystemBarsStyleUtils.applyFullscreenDark(window, view)
} }
@@ -1572,6 +1629,7 @@ fun MultiImageEditorScreen(
val photoEditors = remember { mutableStateMapOf<Int, PhotoEditor?>() } val photoEditors = remember { mutableStateMapOf<Int, PhotoEditor?>() }
val photoEditorViews = remember { mutableStateMapOf<Int, PhotoEditorView?>() } val photoEditorViews = remember { mutableStateMapOf<Int, PhotoEditorView?>() }
val drawingEditPages = remember { mutableSetOf<Int>() }
val cropLauncher = rememberLauncherForActivityResult( val cropLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult() contract = ActivityResultContracts.StartActivityForResult()
@@ -1605,11 +1663,11 @@ fun MultiImageEditorScreen(
BackHandler { animatedDismiss() } BackHandler { animatedDismiss() }
// ⚡ Простой fade - только opacity
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color.Black)
.zIndex(100f)
) { ) {
Box( Box(
modifier = modifier =
@@ -1834,6 +1892,7 @@ fun MultiImageEditorScreen(
showColorPicker = !showColorPicker showColorPicker = !showColorPicker
} else { } else {
currentTool = EditorTool.DRAW currentTool = EditorTool.DRAW
drawingEditPages.add(pagerState.currentPage)
currentEditor?.setBrushDrawingMode(true) currentEditor?.setBrushDrawingMode(true)
currentEditor?.brushColor = selectedColor.toArgb() currentEditor?.brushColor = selectedColor.toArgb()
currentEditor?.brushSize = brushSize currentEditor?.brushSize = brushSize
@@ -1875,7 +1934,7 @@ fun MultiImageEditorScreen(
val originalImage = imagesToSend[i] val originalImage = imagesToSend[i]
if (editor != null) { if (editor != null) {
val savedUri = saveEditedImageSync(context, editor, editorView, originalImage.uri) val savedUri = saveEditedImageSync(context, editor, editorView, originalImage.uri, hasDrawingEdits = i in drawingEditPages)
if (savedUri != null) { if (savedUri != null) {
savedImages.add(originalImage.copy(uri = savedUri)) savedImages.add(originalImage.copy(uri = savedUri))
} else { } else {
@@ -1906,6 +1965,7 @@ fun MultiImageEditorScreen(
} }
} }
} }
} // Dialog
} }
private fun loadBitmapRespectExif(context: Context, uri: Uri): Bitmap? { private fun loadBitmapRespectExif(context: Context, uri: Uri): Bitmap? {

View File

@@ -56,6 +56,7 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import com.rosetta.messenger.data.PreferencesManager
/** /**
* 📷 In-App Camera Screen - как в Telegram * 📷 In-App Camera Screen - как в Telegram
@@ -73,11 +74,18 @@ fun InAppCameraScreen(
val view = LocalView.current val view = LocalView.current
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val preferencesManager = remember(context.applicationContext) {
PreferencesManager(context.applicationContext)
}
// Camera state // Camera state
var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) } var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) }
var flashMode by remember { mutableStateOf(ImageCapture.FLASH_MODE_AUTO) } var flashMode by remember { mutableStateOf(ImageCapture.FLASH_MODE_AUTO) }
var isCapturing by remember { mutableStateOf(false) } var isCapturing by remember { mutableStateOf(false) }
LaunchedEffect(preferencesManager) {
flashMode = preferencesManager.getCameraFlashMode()
}
// Camera references // Camera references
var imageCapture by remember { mutableStateOf<ImageCapture?>(null) } var imageCapture by remember { mutableStateOf<ImageCapture?>(null) }
@@ -231,27 +239,27 @@ fun InAppCameraScreen(
// PreviewView reference // PreviewView reference
var previewView by remember { mutableStateOf<PreviewView?>(null) } var previewView by remember { mutableStateOf<PreviewView?>(null) }
// Bind camera when previewView, lensFacing or flashMode changes // Bind camera when previewView or lensFacing changes (NOT flashMode — that causes flicker)
LaunchedEffect(previewView, lensFacing, flashMode) { LaunchedEffect(previewView, lensFacing) {
val pv = previewView ?: return@LaunchedEffect val pv = previewView ?: return@LaunchedEffect
val provider = context.getCameraProvider() val provider = context.getCameraProvider()
cameraProvider = provider cameraProvider = provider
val preview = Preview.Builder().build().also { val preview = Preview.Builder().build().also {
it.setSurfaceProvider(pv.surfaceProvider) it.setSurfaceProvider(pv.surfaceProvider)
} }
val capture = ImageCapture.Builder() val capture = ImageCapture.Builder()
.setFlashMode(flashMode) .setFlashMode(flashMode)
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.build() .build()
imageCapture = capture imageCapture = capture
val cameraSelector = CameraSelector.Builder() val cameraSelector = CameraSelector.Builder()
.requireLensFacing(lensFacing) .requireLensFacing(lensFacing)
.build() .build()
try { try {
provider.unbindAll() provider.unbindAll()
provider.bindToLifecycle( provider.bindToLifecycle(
@@ -263,6 +271,11 @@ fun InAppCameraScreen(
} catch (e: Exception) { } catch (e: Exception) {
} }
} }
// Apply flash mode directly without rebinding camera (prevents flicker)
LaunchedEffect(flashMode) {
imageCapture?.flashMode = flashMode
}
// Unbind on dispose // Unbind on dispose
DisposableEffect(Unit) { DisposableEffect(Unit) {
@@ -318,11 +331,15 @@ fun InAppCameraScreen(
// Flash button // Flash button
IconButton( IconButton(
onClick = { onClick = {
flashMode = when (flashMode) { val nextFlashMode = when (flashMode) {
ImageCapture.FLASH_MODE_OFF -> ImageCapture.FLASH_MODE_AUTO ImageCapture.FLASH_MODE_OFF -> ImageCapture.FLASH_MODE_AUTO
ImageCapture.FLASH_MODE_AUTO -> ImageCapture.FLASH_MODE_ON ImageCapture.FLASH_MODE_AUTO -> ImageCapture.FLASH_MODE_ON
else -> ImageCapture.FLASH_MODE_OFF else -> ImageCapture.FLASH_MODE_OFF
} }
flashMode = nextFlashMode
scope.launch {
preferencesManager.setCameraFlashMode(nextFlashMode)
}
}, },
modifier = Modifier modifier = Modifier
.size(44.dp) .size(44.dp)

View File

@@ -79,6 +79,7 @@ fun AvatarImage(
displayName: String? = null // 🔥 Имя для инициалов (title/username) displayName: String? = null // 🔥 Имя для инициалов (title/username)
) { ) {
val isSystemSafeAccount = publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY val isSystemSafeAccount = publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY
val isSystemUpdatesAccount = publicKey == MessageRepository.SYSTEM_UPDATES_PUBLIC_KEY
// Получаем аватары из репозитория // Получаем аватары из репозитория
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState() val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
@@ -141,6 +142,13 @@ fun AvatarImage(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
} else if (isSystemUpdatesAccount) {
Image(
painter = painterResource(id = R.drawable.updates_account),
contentDescription = "Rosetta Updates avatar",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else if (bitmap != null) { } else if (bitmap != null) {
// Отображаем реальный аватар // Отображаем реальный аватар
Image( Image(

View File

@@ -42,61 +42,7 @@ fun BoxScope.BlurredAvatarBackground(
overlayColors: List<Color>? = null, overlayColors: List<Color>? = null,
isDarkTheme: Boolean = true isDarkTheme: Boolean = true
) { ) {
// Если выбран цвет в Appearance — рисуем блюр аватарки + полупрозрачный overlay поверх // Загрузка и blur аватарки — нужна ВСЕГДА (и для overlay-цветов, и для обычного режима)
// (одинаково для светлой и тёмной темы, чтобы цвет совпадал с превью в Appearance)
if (overlayColors != null && overlayColors.isNotEmpty()) {
// Загружаем блюр аватарки для подложки
val avatarsForOverlay by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) }
val avatarKeyForOverlay = remember(avatarsForOverlay) {
avatarsForOverlay.firstOrNull()?.timestamp ?: 0L
}
var blurredForOverlay by remember { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(avatarKeyForOverlay) {
val currentAvatars = avatarsForOverlay
if (currentAvatars.isNotEmpty()) {
val original = withContext(Dispatchers.IO) {
AvatarFileManager.base64ToBitmap(currentAvatars.first().base64Data)
}
if (original != null) {
blurredForOverlay = withContext(Dispatchers.Default) {
val scaled = Bitmap.createScaledBitmap(original, original.width / 4, original.height / 4, true)
var result = scaled
repeat(2) { result = fastBlur(result, (blurRadius / 4).toInt().coerceAtLeast(1)) }
result
}
}
} else {
blurredForOverlay = null
}
}
// Подложка: блюр аватарки или fallback цвет
Box(modifier = Modifier.matchParentSize()) {
if (blurredForOverlay != null) {
Image(
bitmap = blurredForOverlay!!.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize().graphicsLayer { this.alpha = 0.35f },
contentScale = ContentScale.Crop
)
}
}
// Overlay — полупрозрачный, как в Appearance preview
val overlayAlpha = if (blurredForOverlay != null) 0.55f else 0.85f
val overlayMod = if (overlayColors.size == 1) {
Modifier.matchParentSize().background(overlayColors[0].copy(alpha = overlayAlpha))
} else {
Modifier.matchParentSize().background(
Brush.linearGradient(colors = overlayColors.map { it.copy(alpha = overlayAlpha) })
)
}
Box(modifier = overlayMod)
return
}
// Нет фона (avatar default) — blur аватарки
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState() val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) } ?: remember { mutableStateOf(emptyList()) }
@@ -137,22 +83,68 @@ fun BoxScope.BlurredAvatarBackground(
} }
} }
Box(modifier = Modifier.matchParentSize()) { // ═══════════════════════════════════════════════════════════
// Если выбран overlay-цвет — рисуем blur + пастельный overlay
// (повторяет логику ProfileBlurPreview из AppearanceScreen)
// ═══════════════════════════════════════════════════════════
if (overlayColors != null && overlayColors.isNotEmpty()) {
// LAYER 1: Blurred avatar underneath (если есть)
if (blurredBitmap != null) { if (blurredBitmap != null) {
Image( Image(
bitmap = blurredBitmap!!.asImageBitmap(), bitmap = blurredBitmap!!.asImageBitmap(),
contentDescription = null, contentDescription = null,
modifier = Modifier.fillMaxSize(), modifier = Modifier.matchParentSize()
.graphicsLayer { this.alpha = 0.35f },
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
} else {
// Нет фото — цвет аватарки
Box(
modifier = Modifier
.fillMaxSize()
.background(fallbackColor)
)
} }
// LAYER 2: Цвет/градиент overlay с пониженной прозрачностью
// С blur-подложкой — 55%, без — 85% (как в AppearanceScreen preview)
val colorAlpha = if (blurredBitmap != null) 0.55f else 0.85f
val overlayMod = if (overlayColors.size == 1) {
Modifier.matchParentSize()
.background(overlayColors[0].copy(alpha = colorAlpha))
} else {
Modifier.matchParentSize()
.background(
Brush.linearGradient(
colors = overlayColors.map { it.copy(alpha = colorAlpha) }
)
)
}
Box(modifier = overlayMod)
return
}
// ═══════════════════════════════════════════════════════════
// Стандартный режим (нет overlay-цвета) — blur аватарки или fallback
// Повторяет логику AppearanceScreen → ProfileBlurPreview:
// blur 35% alpha + цвет-тинт 30% alpha
// ═══════════════════════════════════════════════════════════
if (blurredBitmap != null) {
Image(
bitmap = blurredBitmap!!.asImageBitmap(),
contentDescription = null,
modifier = Modifier.matchParentSize()
.graphicsLayer { this.alpha = 0.35f },
contentScale = ContentScale.Crop
)
// Тонкий цветовой тинт поверх blur (как в AppearanceScreen preview)
Box(
modifier = Modifier
.matchParentSize()
.background(fallbackColor.copy(alpha = 0.3f))
)
} else {
// Нет фото — fallback: серый в светлой, тёмный в тёмной (как в AppearanceScreen)
Box(
modifier = Modifier
.matchParentSize()
.background(
if (isDarkTheme) Color(0xFF2A2A2E) else Color(0xFFD8D8DC)
)
)
} }
} }
@@ -264,4 +256,3 @@ private fun blurColumn(pixels: IntArray, x: Int, w: Int, h: Int, radius: Int) {
sumB += (bottomPixel and 0xff) - (topPixel and 0xff) sumB += (bottomPixel and 0xff) - (topPixel and 0xff)
} }
} }

View File

@@ -99,35 +99,42 @@ fun SwipeBackContainer(
val scrimAlpha = (1f - (currentOffset / screenWidthPx).coerceIn(0f, 1f)) * 0.4f val scrimAlpha = (1f - (currentOffset / screenWidthPx).coerceIn(0f, 1f)) * 0.4f
// Handle visibility changes // Handle visibility changes
// 🔥 FIX: try/finally ensures animation flags are ALWAYS reset even if
// LaunchedEffect is cancelled by rapid isVisible changes (fast swipes).
// Without this, isAnimatingIn/isAnimatingOut can stay true forever,
// leaving an invisible overlay that blocks all touches on the chat list.
LaunchedEffect(isVisible) { LaunchedEffect(isVisible) {
if (isVisible && !shouldShow) { if (isVisible && !shouldShow) {
// Animate in: fade-in // Animate in: fade-in
shouldShow = true shouldShow = true
isAnimatingIn = true isAnimatingIn = true
offsetAnimatable.snapTo(0f) // No slide for entry try {
alphaAnimatable.snapTo(0f) offsetAnimatable.snapTo(0f) // No slide for entry
alphaAnimatable.animateTo( alphaAnimatable.snapTo(0f)
targetValue = 1f, alphaAnimatable.animateTo(
animationSpec = targetValue = 1f,
tween( animationSpec =
durationMillis = ANIMATION_DURATION_ENTER, tween(
easing = FastOutSlowInEasing durationMillis = ANIMATION_DURATION_ENTER,
) easing = FastOutSlowInEasing
) )
isAnimatingIn = false )
} else if (!isVisible && shouldShow && !isAnimatingOut) { } finally {
isAnimatingIn = false
}
} else if (!isVisible && shouldShow) {
// Animate out: fade-out (when triggered by button, not swipe) // Animate out: fade-out (when triggered by button, not swipe)
isAnimatingOut = true isAnimatingOut = true
alphaAnimatable.snapTo(1f) try {
alphaAnimatable.animateTo( alphaAnimatable.animateTo(
targetValue = 0f, targetValue = 0f,
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing) animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing)
) )
shouldShow = false } finally {
isAnimatingOut = false shouldShow = false
offsetAnimatable.snapTo(0f) isAnimatingOut = false
alphaAnimatable.snapTo(0f) dragOffset = 0f
dragOffset = 0f }
} }
} }
@@ -286,6 +293,13 @@ fun SwipeBackContainer(
TelegramEasing TelegramEasing
) )
) )
// 🔥 FIX: Reset state BEFORE onBack() to prevent
// redundant fade-out animation in LaunchedEffect.
// Without this, onBack() changes isVisible→false,
// triggering a 200ms fade-out during which an invisible
// overlay blocks all touches on the chat list.
shouldShow = false
dragOffset = 0f
onBack() onBack()
} else { } else {
offsetAnimatable.animateTo( offsetAnimatable.animateTo(
@@ -298,9 +312,8 @@ fun SwipeBackContainer(
TelegramEasing TelegramEasing
) )
) )
dragOffset = 0f
} }
dragOffset = 0f
} }
} }
} }

View File

@@ -28,18 +28,21 @@ fun VerifiedBadge(
verified: Int, verified: Int,
size: Int = 16, size: Int = 16,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isDarkTheme: Boolean = isSystemInDarkTheme() isDarkTheme: Boolean = isSystemInDarkTheme(),
badgeTint: Color? = null
) { ) {
if (verified <= 0) return if (verified <= 0) return
var showDialog by remember { mutableStateOf(false) } var showDialog by remember { mutableStateOf(false) }
// Цвет в зависимости от уровня верификации // Цвет в зависимости от уровня верификации
val badgeColor = when (verified) { val badgeColor =
1 -> Color(0xFF1DA1F2) // Стандартная верификация (синий как в Twitter/Telegram) badgeTint
2 -> Color(0xFFFFD700) // Золотая верификация ?: when (verified) {
else -> Color(0xFF4CAF50) // Зеленая для других уровней 1 -> Color(0xFF1DA1F2) // Стандартная верификация (синий как в Twitter/Telegram)
} 2 -> Color(0xFFFFD700) // Золотая верификация
else -> Color(0xFF4CAF50) // Зеленая для других уровней
}
// Текст аннотации // Текст аннотации
val annotationText = when (verified) { val annotationText = when (verified) {

View File

@@ -1,10 +1,17 @@
package com.rosetta.messenger.ui.settings package com.rosetta.messenger.ui.settings
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.PixelCopy
import android.view.View
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
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.border import androidx.compose.foundation.border
@@ -24,23 +31,34 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.BlendMode
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.Path
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
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 com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.utils.AvatarFileManager import com.rosetta.messenger.utils.AvatarFileManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import compose.icons.tablericons.Sun import compose.icons.tablericons.Sun
import compose.icons.tablericons.Moon import compose.icons.tablericons.Moon
import kotlin.math.hypot
import kotlin.math.max
/** /**
* Экран кастомизации внешнего вида. * Экран кастомизации внешнего вида.
@@ -74,9 +92,85 @@ fun AppearanceScreen(
var selectedId by remember { mutableStateOf(currentBlurColorId) } var selectedId by remember { mutableStateOf(currentBlurColorId) }
// ── Circular reveal state ──
val scope = rememberCoroutineScope()
var screenshotBitmap by remember { mutableStateOf<Bitmap?>(null) }
val revealProgress = remember { Animatable(1f) } // 1 = fully revealed (no overlay)
var revealCenter by remember { mutableStateOf(Offset.Zero) }
var screenSize by remember { mutableStateOf(IntSize.Zero) }
// Helper: capture screenshot of the current view, then animate circular reveal
fun triggerCircularReveal(center: Offset, applyChange: () -> Unit) {
// Don't start a new animation while one is running
if (screenshotBitmap != null) return
val rootView = view.rootView
val width = rootView.width
val height = rootView.height
if (width <= 0 || height <= 0) {
applyChange()
return
}
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
fun onScreenshotReady() {
screenshotBitmap = bmp
revealCenter = center
scope.launch {
revealProgress.snapTo(0f)
applyChange()
revealProgress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 380)
)
screenshotBitmap = null
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val window = (view.context as? android.app.Activity)?.window
if (window != null) {
PixelCopy.request(
window,
bmp,
{ result ->
if (result == PixelCopy.SUCCESS) {
onScreenshotReady()
} else {
applyChange()
}
},
Handler(Looper.getMainLooper())
)
return
}
}
// Fallback for older APIs
@Suppress("DEPRECATION")
rootView.isDrawingCacheEnabled = true
@Suppress("DEPRECATION")
rootView.buildDrawingCache()
@Suppress("DEPRECATION")
val cache = rootView.drawingCache
if (cache != null) {
val canvas = android.graphics.Canvas(bmp)
canvas.drawBitmap(cache, 0f, 0f, null)
@Suppress("DEPRECATION")
rootView.destroyDrawingCache()
onScreenshotReady()
} else {
applyChange()
}
}
BackHandler { onBack() } BackHandler { onBack() }
Box(modifier = Modifier.fillMaxSize()) { Box(
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { screenSize = it.size }
) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -119,7 +213,18 @@ fun AppearanceScreen(
tint = Color.White tint = Color.White
) )
} }
IconButton(onClick = { onToggleTheme() }) { var themeButtonCenter by remember { mutableStateOf(Offset.Zero) }
IconButton(
onClick = {
triggerCircularReveal(themeButtonCenter) {
onToggleTheme()
}
},
modifier = Modifier.onGloballyPositioned { coords ->
val bounds = coords.boundsInRoot()
themeButtonCenter = bounds.center
}
) {
Icon( Icon(
imageVector = if (isDarkTheme) TablerIcons.Sun else TablerIcons.Moon, imageVector = if (isDarkTheme) TablerIcons.Sun else TablerIcons.Moon,
contentDescription = "Toggle theme", contentDescription = "Toggle theme",
@@ -149,9 +254,13 @@ fun AppearanceScreen(
ColorSelectionGrid( ColorSelectionGrid(
selectedId = selectedId, selectedId = selectedId,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onSelect = { id -> onSelect = { id, centerInRoot ->
selectedId = id if (id != selectedId) {
onBlurColorChange(id) triggerCircularReveal(centerInRoot) {
selectedId = id
onBlurColorChange(id)
}
}
} }
) )
@@ -168,6 +277,37 @@ fun AppearanceScreen(
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
} }
} }
// ── Circular reveal overlay ──
// Screenshot of old state is drawn with a circular hole that grows,
// revealing the new state underneath.
val bmp = screenshotBitmap
if (bmp != null) {
val progress = revealProgress.value
val maxRadius = hypot(
max(revealCenter.x, screenSize.width - revealCenter.x),
max(revealCenter.y, screenSize.height - revealCenter.y)
)
val currentRadius = maxRadius * progress
Canvas(
modifier = Modifier
.fillMaxSize()
.graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen }
) {
// Draw the old screenshot
drawImage(
image = bmp.asImageBitmap()
)
// Cut a circular hole — reveals new content underneath
drawCircle(
color = Color.Black,
radius = currentRadius,
center = revealCenter,
blendMode = BlendMode.DstOut
)
}
}
} }
} }
@@ -413,7 +553,7 @@ private fun ProfileBlurPreview(
private fun ColorSelectionGrid( private fun ColorSelectionGrid(
selectedId: String, selectedId: String,
isDarkTheme: Boolean, isDarkTheme: Boolean,
onSelect: (String) -> Unit onSelect: (String, Offset) -> Unit
) { ) {
val allOptions = BackgroundBlurPresets.allWithDefault val allOptions = BackgroundBlurPresets.allWithDefault
val horizontalPadding = 12.dp val horizontalPadding = 12.dp
@@ -460,7 +600,7 @@ private fun ColorSelectionGrid(
isSelected = option.id == selectedId, isSelected = option.id == selectedId,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
circleSize = circleSize, circleSize = circleSize,
onClick = { onSelect(option.id) } onSelectWithPosition = { centerInRoot -> onSelect(option.id, centerInRoot) }
) )
} }
repeat(columns - rowItems.size) { repeat(columns - rowItems.size) {
@@ -478,7 +618,7 @@ private fun ColorCircleItem(
isSelected: Boolean, isSelected: Boolean,
isDarkTheme: Boolean, isDarkTheme: Boolean,
circleSize: Dp, circleSize: Dp,
onClick: () -> Unit onSelectWithPosition: (Offset) -> Unit
) { ) {
val scale by animateFloatAsState( val scale by animateFloatAsState(
targetValue = if (isSelected) 1.08f else 1.0f, targetValue = if (isSelected) 1.08f else 1.0f,
@@ -496,17 +636,26 @@ private fun ColorCircleItem(
label = "border" label = "border"
) )
// Track center position in root coordinates for circular reveal
var boundsInRoot by remember { mutableStateOf(Rect.Zero) }
Box( Box(
modifier = Modifier modifier = Modifier
.size(circleSize) .size(circleSize)
.scale(scale) .scale(scale)
.onGloballyPositioned { coords ->
boundsInRoot = coords.boundsInRoot()
}
.clip(CircleShape) .clip(CircleShape)
.border( .border(
width = if (isSelected) 2.5.dp else 0.5.dp, width = if (isSelected) 2.5.dp else 0.5.dp,
color = if (isSelected) borderColor else if (isDarkTheme) Color.White.copy(alpha = 0.12f) else Color.Black.copy(alpha = 0.12f), color = if (isSelected) borderColor else if (isDarkTheme) Color.White.copy(alpha = 0.12f) else Color.Black.copy(alpha = 0.12f),
shape = CircleShape shape = CircleShape
) )
.clickable(onClick = onClick), .clickable {
val center = boundsInRoot.center
onSelectWithPosition(center)
},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
when { when {

View File

@@ -201,6 +201,9 @@ fun OtherProfileScreen(
val isSafetyProfile = remember(user.publicKey) { val isSafetyProfile = remember(user.publicKey) {
user.publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY user.publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY
} }
val isSystemAccount = remember(user.publicKey) {
MessageRepository.isSystemAccount(user.publicKey)
}
val context = LocalContext.current val context = LocalContext.current
val view = LocalView.current val view = LocalView.current
val window = remember { (view.context as? Activity)?.window } val window = remember { (view.context as? Activity)?.window }
@@ -590,7 +593,7 @@ fun OtherProfileScreen(
) )
} }
if (!isSafetyProfile) { if (!isSafetyProfile && !isSystemAccount) {
// Call // Call
Button( Button(
onClick = { /* TODO: call action */ }, onClick = { /* TODO: call action */ },
@@ -791,6 +794,7 @@ fun OtherProfileScreen(
showAvatarMenu = showAvatarMenu, showAvatarMenu = showAvatarMenu,
onAvatarMenuChange = { showAvatarMenu = it }, onAvatarMenuChange = { showAvatarMenu = it },
isBlocked = isBlocked, isBlocked = isBlocked,
isSystemAccount = isSystemAccount,
onBlockToggle = { onBlockToggle = {
coroutineScope.launch { coroutineScope.launch {
if (isBlocked) { if (isBlocked) {
@@ -1727,7 +1731,7 @@ private fun OtherProfileEmptyState(
@Composable @Composable
private fun CollapsingOtherProfileHeader( private fun CollapsingOtherProfileHeader(
name: String, name: String,
@Suppress("UNUSED_PARAMETER") username: String, username: String,
publicKey: String, publicKey: String,
verified: Int, verified: Int,
isOnline: Boolean, isOnline: Boolean,
@@ -1740,6 +1744,7 @@ private fun CollapsingOtherProfileHeader(
showAvatarMenu: Boolean, showAvatarMenu: Boolean,
onAvatarMenuChange: (Boolean) -> Unit, onAvatarMenuChange: (Boolean) -> Unit,
isBlocked: Boolean, isBlocked: Boolean,
isSystemAccount: Boolean = false,
onBlockToggle: () -> Unit, onBlockToggle: () -> Unit,
avatarRepository: AvatarRepository? = null, avatarRepository: AvatarRepository? = null,
onClearChat: () -> Unit, onClearChat: () -> Unit,
@@ -1769,6 +1774,10 @@ private fun CollapsingOtherProfileHeader(
val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress) val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress)
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress) val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress)
val rosettaBadgeBlue = Color(0xFF1DA1F2)
val isRosettaOfficial =
name.equals("Rosetta", ignoreCase = true) ||
username.equals("rosetta", ignoreCase = true)
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой // 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой
@@ -1878,6 +1887,13 @@ private fun CollapsingOtherProfileHeader(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
} else if (publicKey == MessageRepository.SYSTEM_UPDATES_PUBLIC_KEY) {
Image(
painter = painterResource(id = R.drawable.updates_account),
contentDescription = "Rosetta Updates avatar",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else if (hasAvatar && avatarRepository != null) { } else if (hasAvatar && avatarRepository != null) {
OtherProfileFullSizeAvatar( OtherProfileFullSizeAvatar(
publicKey = publicKey, publicKey = publicKey,
@@ -1975,6 +1991,7 @@ private fun CollapsingOtherProfileHeader(
onDismiss = { onAvatarMenuChange(false) }, onDismiss = { onAvatarMenuChange(false) },
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
isBlocked = isBlocked, isBlocked = isBlocked,
isSystemAccount = isSystemAccount,
onBlockClick = { onBlockClick = {
onAvatarMenuChange(false) onAvatarMenuChange(false)
onBlockToggle() onBlockToggle()
@@ -2010,9 +2027,14 @@ private fun CollapsingOtherProfileHeader(
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
if (verified > 0) { if (verified > 0 || isRosettaOfficial) {
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge(verified = verified, size = (nameFontSize.value * 0.8f).toInt(), isDarkTheme = isDarkTheme) VerifiedBadge(
verified = if (verified > 0) verified else 1,
size = (nameFontSize.value * 0.8f).toInt(),
isDarkTheme = isDarkTheme,
badgeTint = if (isRosettaOfficial) rosettaBadgeBlue else null
)
} }
} }

View File

@@ -61,6 +61,7 @@ import com.rosetta.messenger.biometric.BiometricAvailability
import com.rosetta.messenger.biometric.BiometricPreferences import com.rosetta.messenger.biometric.BiometricPreferences
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.BlurredAvatarBackground 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.PrimaryBlue
import com.rosetta.messenger.ui.onboarding.PrimaryBlueDark import com.rosetta.messenger.ui.onboarding.PrimaryBlueDark
@@ -1067,7 +1068,7 @@ fun ProfileScreen(
@Composable @Composable
private fun CollapsingProfileHeader( private fun CollapsingProfileHeader(
name: String, name: String,
@Suppress("UNUSED_PARAMETER") username: String, username: String,
publicKey: String, publicKey: String,
avatarColors: AvatarColors, avatarColors: AvatarColors,
collapseProgress: Float, collapseProgress: Float,
@@ -1128,11 +1129,17 @@ private fun CollapsingProfileHeader(
// Font sizes // Font sizes
val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress) val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress)
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress) val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress)
val rosettaBadgeBlue = Color(0xFF1DA1F2)
val isRosettaOfficial =
name.equals("Rosetta", ignoreCase = true) ||
username.equals("rosetta", ignoreCase = true)
Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) { Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) {
// Expansion fraction — computed early so gradient can fade during expansion // Expansion fraction — computed early so gradient can fade during expansion
val expandFraction = expansionProgress.coerceIn(0f, 1f) val expandFraction = expansionProgress.coerceIn(0f, 1f)
val headerBaseColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4) val headerBaseColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4)
// Neutral screen bg — used under blur/overlay so blue doesn't bleed through
val screenBgColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 🎨 BLURRED AVATAR BACKGROUND — ВСЕГДА видим // 🎨 BLURRED AVATAR BACKGROUND — ВСЕГДА видим
@@ -1140,15 +1147,16 @@ private fun CollapsingProfileHeader(
// и естественно перекрывает его. Без мерцания. // и естественно перекрывает его. Без мерцания.
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
Box(modifier = Modifier.matchParentSize()) { Box(modifier = Modifier.matchParentSize()) {
Box(modifier = Modifier.matchParentSize().background(headerBaseColor))
if (backgroundBlurColorId == "none") { if (backgroundBlurColorId == "none") {
// None — стандартный цвет шапки без blur // None — стандартный цвет шапки без blur
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .matchParentSize()
.background(headerBaseColor) .background(headerBaseColor)
) )
} else { } else {
// Neutral base so transparent blur layers don't pick up blue tint
Box(modifier = Modifier.matchParentSize().background(screenBgColor))
BlurredAvatarBackground( BlurredAvatarBackground(
publicKey = publicKey, publicKey = publicKey,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
@@ -1375,16 +1383,30 @@ private fun CollapsingProfileHeader(
Modifier.align(Alignment.TopCenter).offset(y = textY), Modifier.align(Alignment.TopCenter).offset(y = textY),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( Row(
text = name, verticalAlignment = Alignment.CenterVertically,
fontSize = nameFontSize, horizontalArrangement = Arrangement.Center
fontWeight = FontWeight.SemiBold, ) {
color = textColor, Text(
maxLines = 1, text = name,
overflow = TextOverflow.Ellipsis, fontSize = nameFontSize,
modifier = Modifier.widthIn(max = 220.dp), fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center color = textColor,
) maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 220.dp),
textAlign = TextAlign.Center
)
if (isRosettaOfficial) {
Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge(
verified = 2,
size = (nameFontSize.value * 0.8f).toInt(),
isDarkTheme = isDarkTheme,
badgeTint = rosettaBadgeBlue
)
}
}
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))

View File

@@ -1,11 +1,14 @@
package com.rosetta.messenger.ui.settings package com.rosetta.messenger.ui.settings
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -20,13 +23,42 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment 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.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
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.core.view.drawToBitmap
import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.components.AppleEmojiText
import kotlin.math.hypot
import kotlinx.coroutines.launch
private fun maxRevealRadius(center: Offset, bounds: IntSize): Float {
if (bounds.width <= 0 || bounds.height <= 0) return 0f
val width = bounds.width.toFloat()
val height = bounds.height.toFloat()
return maxOf(
hypot(center.x, center.y),
hypot(width - center.x, center.y),
hypot(center.x, height - center.y),
hypot(width - center.x, height - center.y)
)
}
@Composable @Composable
fun ThemeScreen( fun ThemeScreen(
@@ -49,123 +81,251 @@ fun ThemeScreen(
val surfaceColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) val surfaceColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
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(0xFF666666) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val scope = rememberCoroutineScope()
val systemIsDark = isSystemInDarkTheme()
// Theme mode: "light", "dark", "auto" // Theme mode: "light", "dark", "auto"
var themeMode by remember { mutableStateOf(currentThemeMode) } var themeMode by remember { mutableStateOf(currentThemeMode) }
val themeRevealRadius = remember { Animatable(0f) }
var rootSize by remember { mutableStateOf(IntSize.Zero) }
var themeRevealActive by remember { mutableStateOf(false) }
var themeRevealToDark by remember { mutableStateOf(false) }
var themeRevealCenter by remember { mutableStateOf(Offset.Zero) }
var themeRevealSnapshot by remember { mutableStateOf<ImageBitmap?>(null) }
var lightOptionCenter by remember { mutableStateOf<Offset?>(null) }
var darkOptionCenter by remember { mutableStateOf<Offset?>(null) }
var systemOptionCenter by remember { mutableStateOf<Offset?>(null) }
LaunchedEffect(currentThemeMode) {
themeMode = currentThemeMode
}
fun resolveThemeIsDark(mode: String): Boolean =
when (mode) {
"dark" -> true
"light" -> false
else -> systemIsDark
}
fun startThemeReveal(targetMode: String, centerHint: Offset?) {
if (themeRevealActive || themeMode == targetMode) return
val targetIsDark = resolveThemeIsDark(targetMode)
if (targetIsDark == isDarkTheme || rootSize.width <= 0 || rootSize.height <= 0) {
themeMode = targetMode
onThemeModeChange(targetMode)
return
}
val center =
centerHint ?: Offset(rootSize.width * 0.85f, rootSize.height * 0.18f)
val snapshotBitmap = runCatching { view.drawToBitmap() }.getOrNull()
if (snapshotBitmap == null) {
themeMode = targetMode
onThemeModeChange(targetMode)
return
}
val maxRadius = maxRevealRadius(center, rootSize)
if (maxRadius <= 0f) {
themeMode = targetMode
onThemeModeChange(targetMode)
return
}
themeRevealActive = true
themeRevealToDark = targetIsDark
themeRevealCenter = center
themeRevealSnapshot = snapshotBitmap.asImageBitmap()
scope.launch {
try {
if (targetIsDark) {
themeRevealRadius.snapTo(0f)
} else {
themeRevealRadius.snapTo(maxRadius)
}
themeMode = targetMode
onThemeModeChange(targetMode)
withFrameNanos { }
themeRevealRadius.animateTo(
targetValue = if (targetIsDark) maxRadius else 0f,
animationSpec =
tween(
durationMillis = 400,
easing = CubicBezierEasing(0.45f, 0.05f, 0.55f, 0.95f)
)
)
} finally {
themeRevealSnapshot = null
themeRevealActive = false
}
}
}
// Handle back gesture // Handle back gesture
BackHandler { onBack() } BackHandler { onBack() }
Column( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.onSizeChanged { rootSize = it }
.background(backgroundColor) .background(backgroundColor)
) { ) {
// Top Bar Column(
Surface( modifier = Modifier.fillMaxSize()
modifier = Modifier.fillMaxWidth(),
color = backgroundColor
) { ) {
Row( // Top Bar
modifier = Modifier Surface(
.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()) color = backgroundColor
.padding(horizontal = 4.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton(onClick = onBack) { Row(
Icon( modifier = Modifier
imageVector = TablerIcons.ChevronLeft, .fillMaxWidth()
contentDescription = "Back", .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding())
tint = textColor .padding(horizontal = 4.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(
imageVector = TablerIcons.ChevronLeft,
contentDescription = "Back",
tint = textColor
)
}
Text(
text = "Theme",
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
color = textColor,
modifier = Modifier.padding(start = 8.dp)
) )
} }
Text( }
text = "Theme",
fontSize = 20.sp, // Content
fontWeight = FontWeight.SemiBold, Column(
color = textColor, modifier = Modifier
modifier = Modifier.padding(start = 8.dp) .fillMaxSize()
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(8.dp))
// ═══════════════════════════════════════════════════════
// CHAT PREVIEW - Message bubbles like in real chat
// ═══════════════════════════════════════════════════════
ChatPreview(isDarkTheme = isDarkTheme)
Spacer(modifier = Modifier.height(24.dp))
// ═══════════════════════════════════════════════════════
// MODE SELECTOR - Telegram style
// ═══════════════════════════════════════════════════════
TelegramSectionHeader("Appearance", secondaryTextColor)
Column {
TelegramThemeOption(
icon = TablerIcons.Sun,
title = "Light",
isSelected = themeMode == "light",
onClick = { startThemeReveal("light", lightOptionCenter) },
textColor = textColor,
secondaryTextColor = secondaryTextColor,
showDivider = true,
isDarkTheme = isDarkTheme,
onCenterInRootChanged = { lightOptionCenter = it }
)
TelegramThemeOption(
icon = TablerIcons.Moon,
title = "Dark",
isSelected = themeMode == "dark",
onClick = { startThemeReveal("dark", darkOptionCenter) },
textColor = textColor,
secondaryTextColor = secondaryTextColor,
showDivider = true,
isDarkTheme = isDarkTheme,
onCenterInRootChanged = { darkOptionCenter = it }
)
TelegramThemeOption(
icon = TablerIcons.DeviceMobile,
title = "System",
isSelected = themeMode == "auto",
onClick = { startThemeReveal("auto", systemOptionCenter) },
textColor = textColor,
secondaryTextColor = secondaryTextColor,
showDivider = false,
isDarkTheme = isDarkTheme,
onCenterInRootChanged = { systemOptionCenter = it }
)
}
TelegramInfoText(
text = "System mode automatically switches between light and dark themes based on your device settings.",
secondaryTextColor = secondaryTextColor
) )
Spacer(modifier = Modifier.height(32.dp))
} }
} }
// Content if (themeRevealActive) {
Column( val snapshot = themeRevealSnapshot
modifier = Modifier if (snapshot != null) {
.fillMaxSize() Canvas(
.verticalScroll(rememberScrollState()) modifier =
) { Modifier.fillMaxSize()
Spacer(modifier = Modifier.height(8.dp)) .graphicsLayer(
compositingStrategy = CompositingStrategy.Offscreen
// ═══════════════════════════════════════════════════════ )
// CHAT PREVIEW - Message bubbles like in real chat ) {
// ═══════════════════════════════════════════════════════ val destinationSize =
ChatPreview(isDarkTheme = isDarkTheme) IntSize(
width = size.width.toInt(),
Spacer(modifier = Modifier.height(24.dp)) height = size.height.toInt()
)
// ═══════════════════════════════════════════════════════ if (themeRevealToDark) {
// MODE SELECTOR - Telegram style drawImage(
// ═══════════════════════════════════════════════════════ image = snapshot,
TelegramSectionHeader("Appearance", secondaryTextColor) srcOffset = IntOffset.Zero,
srcSize = IntSize(snapshot.width, snapshot.height),
Column { dstOffset = IntOffset.Zero,
TelegramThemeOption( dstSize = destinationSize
icon = TablerIcons.Sun, )
title = "Light", drawCircle(
isSelected = themeMode == "light", color = Color.Transparent,
onClick = { radius = themeRevealRadius.value,
if (themeMode != "light") { center = themeRevealCenter,
themeMode = "light" blendMode = BlendMode.Clear
onThemeModeChange("light") )
} else {
val radius = themeRevealRadius.value
if (radius > 0f) {
val clipCirclePath =
Path().apply {
addOval(
Rect(
left = themeRevealCenter.x - radius,
top = themeRevealCenter.y - radius,
right = themeRevealCenter.x + radius,
bottom = themeRevealCenter.y + radius
)
)
}
clipPath(clipCirclePath) {
drawImage(
image = snapshot,
srcOffset = IntOffset.Zero,
srcSize = IntSize(snapshot.width, snapshot.height),
dstOffset = IntOffset.Zero,
dstSize = destinationSize
)
}
} }
}, }
textColor = textColor, }
secondaryTextColor = secondaryTextColor,
showDivider = true,
isDarkTheme = isDarkTheme
)
TelegramThemeOption(
icon = TablerIcons.Moon,
title = "Dark",
isSelected = themeMode == "dark",
onClick = {
if (themeMode != "dark") {
themeMode = "dark"
onThemeModeChange("dark")
}
},
textColor = textColor,
secondaryTextColor = secondaryTextColor,
showDivider = true,
isDarkTheme = isDarkTheme
)
TelegramThemeOption(
icon = TablerIcons.DeviceMobile,
title = "System",
isSelected = themeMode == "auto",
onClick = {
if (themeMode != "auto") {
themeMode = "auto"
onThemeModeChange("auto")
}
},
textColor = textColor,
secondaryTextColor = secondaryTextColor,
showDivider = false,
isDarkTheme = isDarkTheme
)
} }
TelegramInfoText(
text = "System mode automatically switches between light and dark themes based on your device settings.",
secondaryTextColor = secondaryTextColor
)
Spacer(modifier = Modifier.height(32.dp))
} }
} }
} }
@@ -190,7 +350,8 @@ private fun TelegramThemeOption(
textColor: Color, textColor: Color,
secondaryTextColor: Color, secondaryTextColor: Color,
showDivider: Boolean, showDivider: Boolean,
isDarkTheme: Boolean isDarkTheme: Boolean,
onCenterInRootChanged: ((Offset) -> Unit)? = null
) { ) {
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0) val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0)
@@ -199,6 +360,15 @@ private fun TelegramThemeOption(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onClick) .clickable(onClick = onClick)
.onGloballyPositioned { coords ->
val pos = coords.positionInRoot()
onCenterInRootChanged?.invoke(
Offset(
x = pos.x + coords.size.width / 2f,
y = pos.y + coords.size.height / 2f
)
)
}
.padding(horizontal = 16.dp, vertical = 14.dp), .padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -262,7 +432,9 @@ private fun ChatPreview(isDarkTheme: Boolean) {
val otherBubbleColor = if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5) val otherBubbleColor = if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
val myTextColor = Color.White // White text on blue bubble val myTextColor = Color.White // White text on blue bubble
val otherTextColor = if (isDarkTheme) Color.White else Color.Black val otherTextColor = if (isDarkTheme) Color.White else Color.Black
val timeColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93) val myTimeColor = Color.White // White time on blue bubble (matches real chat)
val otherTimeColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val myCheckColor = Color.White
Surface( Surface(
modifier = Modifier modifier = Modifier
@@ -289,7 +461,7 @@ private fun ChatPreview(isDarkTheme: Boolean) {
isMe = false, isMe = false,
bubbleColor = otherBubbleColor, bubbleColor = otherBubbleColor,
textColor = otherTextColor, textColor = otherTextColor,
timeColor = timeColor timeColor = otherTimeColor
) )
} }
@@ -304,7 +476,8 @@ private fun ChatPreview(isDarkTheme: Boolean) {
isMe = true, isMe = true,
bubbleColor = myBubbleColor, bubbleColor = myBubbleColor,
textColor = myTextColor, textColor = myTextColor,
timeColor = timeColor timeColor = myTimeColor,
checkmarkColor = myCheckColor
) )
} }
@@ -319,7 +492,7 @@ private fun ChatPreview(isDarkTheme: Boolean) {
isMe = false, isMe = false,
bubbleColor = otherBubbleColor, bubbleColor = otherBubbleColor,
textColor = otherTextColor, textColor = otherTextColor,
timeColor = timeColor timeColor = otherTimeColor
) )
} }
} }
@@ -333,7 +506,8 @@ private fun MessageBubble(
isMe: Boolean, isMe: Boolean,
bubbleColor: Color, bubbleColor: Color,
textColor: Color, textColor: Color,
timeColor: Color timeColor: Color,
checkmarkColor: Color = Color(0xFF4FC3F7)
) { ) {
Surface( Surface(
color = bubbleColor, color = bubbleColor,
@@ -372,7 +546,7 @@ private fun MessageBubble(
Icon( Icon(
painter = TelegramIcons.Done, painter = TelegramIcons.Done,
contentDescription = null, contentDescription = null,
tint = Color(0xFF4FC3F7), // Blue checkmarks for read messages tint = checkmarkColor,
modifier = Modifier.size(14.dp) modifier = Modifier.size(14.dp)
) )
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB