Промежуточный результат для 1.0.4 версии
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
104
app/src/main/java/com/rosetta/messenger/data/DraftManager.kt
Normal file
104
app/src/main/java/com/rosetta/messenger/data/DraftManager.kt
Normal 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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
// ═════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════
|
||||||
|
|||||||
31
app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt
Normal file
31
app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt
Normal 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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -57,6 +59,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)) {
|
||||||
when (currentScreen) {
|
when (currentScreen) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
// Кэшируем текст
|
// Кэшируем текст
|
||||||
|
|||||||
@@ -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)) {
|
||||||
// Заголовок
|
// Заголовок
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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? {
|
||||||
|
|||||||
@@ -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,12 +74,19 @@ 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) }
|
||||||
var cameraProvider by remember { mutableStateOf<ProcessCameraProvider?>(null) }
|
var cameraProvider by remember { mutableStateOf<ProcessCameraProvider?>(null) }
|
||||||
@@ -231,8 +239,8 @@ 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()
|
||||||
@@ -264,6 +272,11 @@ fun InAppCameraScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply flash mode directly without rebinding camera (prevents flicker)
|
||||||
|
LaunchedEffect(flashMode) {
|
||||||
|
imageCapture?.flashMode = flashMode
|
||||||
|
}
|
||||||
|
|
||||||
// Unbind on dispose
|
// Unbind on dispose
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
@@ -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)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
app/src/main/res/drawable/updates_account.png
Normal file
BIN
app/src/main/res/drawable/updates_account.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
Reference in New Issue
Block a user