diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dd27287..83d8043 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown" // ═══════════════════════════════════════════════════════════ // Rosetta versioning — bump here on each release // ═══════════════════════════════════════════════════════════ -val rosettaVersionName = "1.0.3" -val rosettaVersionCode = 3 // Increment on each release +val rosettaVersionName = "1.0.4" +val rosettaVersionCode = 4 // Increment on each release android { namespace = "com.rosetta.messenger" diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index ed79be2..ab7cb87 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -153,6 +153,7 @@ class MainActivity : FragmentActivity() { var hasExistingAccount by remember { mutableStateOf(null) } var currentAccount by remember { mutableStateOf(null) } var accountInfoList by remember { mutableStateOf>(emptyList()) } + var startCreateAccountFlow by remember { mutableStateOf(false) } // Check for existing accounts and build AccountInfo list // Also force logout so user always sees unlock screen on app restart @@ -242,7 +243,9 @@ class MainActivity : FragmentActivity() { hasExistingAccount = screen == "auth_unlock", accounts = accountInfoList, accountManager = accountManager, + startInCreateMode = startCreateAccountFlow, onAuthComplete = { account -> + startCreateAccountFlow = false currentAccount = account hasExistingAccount = true // Save as last logged account @@ -261,6 +264,7 @@ class MainActivity : FragmentActivity() { } }, onLogout = { + startCreateAccountFlow = false // Set currentAccount to null immediately to prevent UI // lag currentAccount = null @@ -287,6 +291,7 @@ class MainActivity : FragmentActivity() { scope.launch { preferencesManager.setThemeMode(mode) } }, onLogout = { + startCreateAccountFlow = false // Set currentAccount to null immediately to prevent UI // lag currentAccount = null @@ -297,6 +302,7 @@ class MainActivity : FragmentActivity() { } }, onDeleteAccount = { + startCreateAccountFlow = false val publicKey = currentAccount?.publicKey ?: return@MainScreen scope.launch { try { @@ -331,14 +337,59 @@ class MainActivity : FragmentActivity() { val accounts = accountManager.getAllAccounts() 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 -> - // 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 scope.launch { com.rosetta.messenger.network.ProtocolManager.disconnect() 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 = {}, onDeleteAccount: () -> Unit = {}, onAccountInfoUpdated: suspend () -> Unit = {}, - onSwitchAccount: (String) -> Unit = {} + onSwitchAccount: (String) -> Unit = {}, + onDeleteAccountFromSidebar: (String) -> Unit = {}, + onAddAccount: () -> Unit = {} ) { val accountPublicKey = account?.publicKey.orEmpty() @@ -739,12 +792,11 @@ fun MainScreen( }, chatsViewModel = chatsListViewModel, avatarRepository = avatarRepository, - onLogout = onLogout, onAddAccount = { - // Logout current account and go to auth screen to add new one - onLogout() + onAddAccount() }, - onSwitchAccount = onSwitchAccount + onSwitchAccount = onSwitchAccount, + onDeleteAccountFromSidebar = onDeleteAccountFromSidebar ) SwipeBackContainer( diff --git a/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt b/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt index 4e857fc..2186fa2 100644 --- a/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt +++ b/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt @@ -1,6 +1,7 @@ package com.rosetta.messenger import android.app.Application +import com.rosetta.messenger.data.DraftManager import com.rosetta.messenger.utils.CrashReportManager /** @@ -19,6 +20,9 @@ class RosettaApplication : Application() { // Инициализируем crash reporter initCrashReporting() + // Инициализируем менеджер черновиков + DraftManager.init(this) + } /** diff --git a/app/src/main/java/com/rosetta/messenger/data/AccountDisplayName.kt b/app/src/main/java/com/rosetta/messenger/data/AccountDisplayName.kt index 0b383cf..48fdabc 100644 --- a/app/src/main/java/com/rosetta/messenger/data/AccountDisplayName.kt +++ b/app/src/main/java/com/rosetta/messenger/data/AccountDisplayName.kt @@ -4,7 +4,11 @@ private const val PLACEHOLDER_ACCOUNT_NAME = "Account" fun resolveAccountDisplayName(publicKey: String, name: String?, username: String?): String { 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 } @@ -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 = name?.trim().orEmpty().equals(PLACEHOLDER_ACCOUNT_NAME, ignoreCase = true) diff --git a/app/src/main/java/com/rosetta/messenger/data/DraftManager.kt b/app/src/main/java/com/rosetta/messenger/data/DraftManager.kt new file mode 100644 index 0000000..b4d58af --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/data/DraftManager.kt @@ -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>(emptyMap()) + val drafts: StateFlow> = _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() + + 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}" + } +} diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 1e9b060..04b5f34 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -100,6 +100,15 @@ class MessageRepository private constructor(private val context: Context) { const val SYSTEM_SAFE_TITLE = "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 для предотвращения дубликатов // LRU кэш с ограничением 1000 элементов - защита от race conditions private val processedMessageIds = @@ -221,6 +230,80 @@ class MessageRepository private constructor(private val context: Context) { _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) { val start = System.currentTimeMillis() diff --git a/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt b/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt index 858ee3c..b5c41dd 100644 --- a/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt +++ b/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt @@ -40,6 +40,7 @@ class PreferencesManager(private val context: Context) { val AUTO_DOWNLOAD_PHOTOS = booleanPreferencesKey("auto_download_photos") val AUTO_DOWNLOAD_VIDEOS = booleanPreferencesKey("auto_download_videos") val AUTO_DOWNLOAD_FILES = booleanPreferencesKey("auto_download_files") + val CAMERA_FLASH_MODE = intPreferencesKey("camera_flash_mode") // 0=off, 1=auto, 2=on // Privacy val SHOW_ONLINE_STATUS = booleanPreferencesKey("show_online_status") @@ -158,6 +159,11 @@ class PreferencesManager(private val context: Context) { val autoDownloadFiles: Flow = context.dataStore.data.map { preferences -> preferences[AUTO_DOWNLOAD_FILES] ?: false } + val cameraFlashMode: Flow = + context.dataStore.data.map { preferences -> + normalizeCameraFlashMode(preferences[CAMERA_FLASH_MODE]) + } + suspend fun setMessageTextSize(value: Int) { 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 } } + 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 // ═════════════════════════════════════════════════════════════ diff --git a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt new file mode 100644 index 0000000..b159acf --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -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) +} diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index 56da17d..e312fb8 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -138,6 +138,10 @@ object ProtocolManager { resyncRequiredAfterAccountInit = false requestSynchronize() } + // Send "Rosetta Updates" message on version change (like desktop useUpdateMessage) + scope.launch { + messageRepository?.checkAndSendVersionUpdateMessage() + } } /** diff --git a/app/src/main/java/com/rosetta/messenger/repository/AvatarRepository.kt b/app/src/main/java/com/rosetta/messenger/repository/AvatarRepository.kt index ea9eed2..57d4111 100644 --- a/app/src/main/java/com/rosetta/messenger/repository/AvatarRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/repository/AvatarRepository.kt @@ -187,8 +187,11 @@ class AvatarRepository( // Удаляем из БД avatarDao.deleteAllAvatars(currentPublicKey) - // Очищаем memory cache + отменяем Job - memoryCache.remove(currentPublicKey)?.job?.cancel() + // Важно: не удаляем cache entry и не отменяем Job здесь. + // Иначе уже подписанные composable продолжают слушать "мертвый" flow + // со старым значением до следующей рекомпозиции. + // Сразу пушим пустой список в существующий flow, чтобы UI обновился мгновенно. + memoryCache[currentPublicKey]?.flow?.value = emptyList() } catch (e: Exception) { throw e diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt index d151d4e..14da96a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt @@ -25,6 +25,7 @@ fun AuthFlow( hasExistingAccount: Boolean, accounts: List = emptyList(), accountManager: AccountManager, + startInCreateMode: Boolean = false, onAuthComplete: (DecryptedAccount?) -> Unit, onLogout: () -> Unit = {} ) { @@ -42,6 +43,7 @@ fun AuthFlow( var currentScreen by remember { mutableStateOf( when { + startInCreateMode -> AuthScreen.WELCOME hasExistingAccount -> AuthScreen.UNLOCK else -> AuthScreen.WELCOME } @@ -56,6 +58,13 @@ fun AuthFlow( } var showCreateModal 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 BackHandler(enabled = currentScreen != AuthScreen.UNLOCK && !(currentScreen == AuthScreen.WELCOME && !hasExistingAccount)) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 095e1ca..08055e8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -72,6 +72,7 @@ import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.SearchUser 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.ImageEditorScreen import com.rosetta.messenger.ui.chats.components.InAppCameraScreen @@ -489,7 +490,10 @@ fun ChatDetailScreen( } // Динамический 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 = when { isSavedMessages -> "Notes" @@ -1044,8 +1048,8 @@ fun ChatDetailScreen( .Ellipsis ) if (!isSavedMessages && - user.verified > - 0 + (user.verified > + 0 || isRosettaOfficial) ) { Spacer( modifier = @@ -1055,7 +1059,7 @@ fun ChatDetailScreen( ) VerifiedBadge( verified = - user.verified, + if (user.verified > 0) user.verified else 1, size = 16, isDarkTheme = @@ -1873,7 +1877,8 @@ fun ChatDetailScreen( else 16.dp ), - reverseLayout = true + reverseLayout = true, + verticalArrangement = Arrangement.Bottom ) { itemsIndexed( messagesWithDates, @@ -2127,57 +2132,98 @@ fun ChatDetailScreen( } } // Конец Column внутри Scaffold content - // 📎 Media Picker INLINE OVERLAY (Telegram-style gallery над клавиатурой) - // Теперь это НЕ Dialog, а обычный composable внутри того же layout! - MediaPickerBottomSheet( - isVisible = showMediaPicker, - onDismiss = { showMediaPicker = false }, - isDarkTheme = isDarkTheme, - currentUserPublicKey = currentUserPublicKey, - onMediaSelected = { selectedMedia, caption -> - // 📸 Отправляем фото напрямую с caption - val imageUris = - selectedMedia.filter { !it.isVideo }.map { it.uri } - - if (imageUris.isNotEmpty()) { + // 📎 Media Picker — new tab-based ChatAttachAlert (Telegram-style) + // Feature flag: set USE_NEW_ATTACH_ALERT to false to use old MediaPickerBottomSheet + val USE_NEW_ATTACH_ALERT = true + if (USE_NEW_ATTACH_ALERT) { + ChatAttachAlert( + isVisible = showMediaPicker, + onDismiss = { showMediaPicker = false }, + isDarkTheme = isDarkTheme, + currentUserPublicKey = currentUserPublicKey, + onMediaSelected = { selectedMedia, caption -> + val imageUris = + selectedMedia.filter { !it.isVideo }.map { it.uri } + if (imageUris.isNotEmpty()) { + showMediaPicker = false + inputFocusTrigger++ + viewModel.sendImageGroupFromUris(imageUris, caption) + } + }, + onMediaSelectedWithCaption = { mediaItem, caption -> showMediaPicker = false inputFocusTrigger++ - viewModel.sendImageGroupFromUris(imageUris, caption) - } - }, - onMediaSelectedWithCaption = { mediaItem, caption -> - // 📸 Отправляем фото с 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 = { - // 👤 Отправляем свой аватар (как в desktop) - viewModel.sendAvatarMessage() - }, - recipientName = user.title - ) + 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 + ) + } else { + MediaPickerBottomSheet( + isVisible = showMediaPicker, + onDismiss = { showMediaPicker = false }, + isDarkTheme = isDarkTheme, + currentUserPublicKey = currentUserPublicKey, + onMediaSelected = { selectedMedia, caption -> + val imageUris = + 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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 4c7201f..87922d2 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -574,6 +574,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога isDialogActive = true // 🔥 Диалог активен! + // 📝 Восстанавливаем черновик для этого диалога (draft, как в Telegram) + val draft = com.rosetta.messenger.data.DraftManager.getDraft(publicKey) + _inputText.value = draft ?: "" + // 📨 Применяем Forward сообщения СРАЗУ после сброса if (hasForward) { // Конвертируем ForwardMessage в ReplyMessage @@ -1598,6 +1602,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { /** Обновить текст ввода */ fun updateInputText(text: String) { _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) _inputText.value = "" - // 🔥 Очищаем reply после отправки - данные сохраняются в displayReplyMessages для анимации + // � Очищаем черновик после отправки + opponentKey?.let { com.rosetta.messenger.data.DraftManager.clearDraft(it) } + + // �🔥 Очищаем reply после отправки - данные сохраняются в displayReplyMessages для анимации clearReplyMessages() // Кэшируем текст diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 5a34c15..0f15b7e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -21,8 +21,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip 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.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.lerp 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.positionChange 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.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.core.view.drawToBitmap import com.airbnb.lottie.compose.* import com.rosetta.messenger.R import com.rosetta.messenger.BuildConfig @@ -70,6 +83,8 @@ import java.text.SimpleDateFormat import java.util.* import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull +import kotlin.math.hypot +import kotlin.math.roundToInt @Immutable data class Chat( @@ -168,6 +183,26 @@ fun getAvatarText(publicKey: String): String { 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) @Composable fun ChatsListScreen( @@ -195,9 +230,9 @@ fun ChatsListScreen( onTogglePin: (String) -> Unit = {}, chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(), avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null, - onLogout: () -> Unit, onAddAccount: () -> Unit = {}, - onSwitchAccount: (String) -> Unit = {} + onSwitchAccount: (String) -> Unit = {}, + onDeleteAccountFromSidebar: (String) -> Unit = {} ) { // Theme transition state var hasInitialized by remember { mutableStateOf(false) } @@ -209,6 +244,67 @@ fun ChatsListScreen( val focusManager = androidx.compose.ui.platform.LocalFocusManager.current val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() + val themeRevealRadius = remember { Animatable(0f) } + var rootSize by remember { mutableStateOf(IntSize.Zero) } + var themeToggleCenterInRoot by remember { mutableStateOf(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(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 // Используем DisposableEffect чтобы срабатывало при каждом появлении экрана @@ -336,6 +432,7 @@ fun ChatsListScreen( var dialogsToDelete by remember { mutableStateOf>(emptyList()) } var dialogToBlock by remember { mutableStateOf(null) } var dialogToUnblock by remember { mutableStateOf(null) } + var accountToDelete by remember { mutableStateOf(null) } var deviceResolveRequest by remember { mutableStateOf?>(null) @@ -520,6 +617,7 @@ fun ChatsListScreen( Box( modifier = Modifier.fillMaxSize() + .onSizeChanged { rootSize = it } .background(backgroundColor) .navigationBarsPadding() ) { @@ -594,6 +692,27 @@ fun ChatsListScreen( Color.White .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), contentAlignment = Alignment.Center @@ -616,8 +735,24 @@ fun ChatsListScreen( // Theme toggle icon IconButton( - onClick = { onToggleTheme() }, - modifier = Modifier.size(48.dp) + onClick = { startThemeReveal() }, + 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( painter = painterResource( @@ -664,7 +799,8 @@ fun ChatsListScreen( ) VerifiedBadge( verified = 1, - size = 15 + size = 15, + badgeTint = Color.White ) } } @@ -726,16 +862,24 @@ fun ChatsListScreen( modifier = Modifier.fillMaxWidth() .height(48.dp) - .clickable { - if (!isCurrentAccount) { - scope.launch { - accountsSectionExpanded = false - drawerState.close() - kotlinx.coroutines.delay(150) - onSwitchAccount(account.publicKey) + .combinedClickable( + onClick = { + if (!isCurrentAccount) { + scope.launch { + accountsSectionExpanded = false + drawerState.close() + kotlinx.coroutines.delay(150) + onSwitchAccount(account.publicKey) + } } + }, + onLongClick = { + hapticFeedback.performHapticFeedback( + HapticFeedbackType.LongPress + ) + accountToDelete = account } - } + ) .padding(start = 14.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -857,8 +1001,8 @@ fun ChatsListScreen( else Color(0xFF889198) val menuTextColor = - if (isDarkTheme) Color(0xFFF4FFFFFF) - else Color(0xFF444444) + if (isDarkTheme) Color(0xFFF4FFFFFF.toInt()) + else Color(0xFF444444.toInt()) val accentColor = if (isDarkTheme) PrimaryBlueDark else PrimaryBlue @@ -933,7 +1077,7 @@ fun ChatsListScreen( } // ═══════════════════════════════════════════════════════════ - // 🚪 FOOTER - Logout & Version + // FOOTER - Version // ═══════════════════════════════════════════════════════════ Column(modifier = Modifier.fillMaxWidth()) { Divider( @@ -944,22 +1088,6 @@ fun ChatsListScreen( 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 Box( modifier = @@ -1667,16 +1795,24 @@ fun ChatsListScreen( } items( - currentDialogs, - key = { it.opponentKey }, - contentType = { "dialog" } + items = currentDialogs, + key = { dialog -> + dialog.opponentKey + }, + contentType = { _ -> + "dialog" + } ) { dialog -> val isSavedMessages = dialog.opponentKey == accountPublicKey + val isPinnedDialog = + pinnedChats + .contains( + dialog.opponentKey + ) val isSystemSafeDialog = - dialog.opponentKey == - MessageRepository.SYSTEM_SAFE_PUBLIC_KEY + MessageRepository.isSystemAccount(dialog.opponentKey) val isBlocked = blockedUsers .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( modifier = @@ -1728,7 +1892,7 @@ fun ChatsListScreen( swipedItemKey == dialog.opponentKey, isSelected = - selectedChatKeys.contains(dialog.opponentKey), + isSelectedDialog, onSwipeStarted = { swipedItemKey = dialog.opponentKey @@ -1780,10 +1944,7 @@ fun ChatsListScreen( dialog }, isPinned = - pinnedChats - .contains( - dialog.opponentKey - ), + isPinnedDialog, swipeEnabled = !isSystemSafeDialog, onPin = { @@ -1796,16 +1957,45 @@ fun ChatsListScreen( // 🔥 СЕПАРАТОР - // линия разделения // между диалогами - Divider( + Box( modifier = - Modifier.padding( - start = - 84.dp - ), - color = - dividerColor, - thickness = - 0.5.dp + Modifier.fillMaxWidth() + .height( + 0.5.dp + ) + .background( + dividerBackgroundColor + ) + ) { + 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 } @@ -2138,13 +2444,23 @@ private fun SkeletonDialogItem(shimmerColor: Color, isDarkTheme: Boolean) { val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) 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 ) { // 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)) { // Name placeholder @@ -2180,7 +2496,7 @@ private fun SkeletonDialogItem(shimmerColor: Color, isDarkTheme: Boolean) { Divider( color = dividerColor, 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.fillMaxWidth() .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding( + horizontal = TELEGRAM_DIALOG_AVATAR_START, + vertical = TELEGRAM_DIALOG_VERTICAL_PADDING + ), verticalAlignment = Alignment.CenterVertically ) { // Avatar with real image AvatarImage( publicKey = chat.publicKey, avatarRepository = avatarRepository, - size = 56.dp, + size = TELEGRAM_DIALOG_AVATAR_SIZE, isDarkTheme = isDarkTheme, showOnlineIndicator = true, isOnline = chat.isOnline, displayName = chat.name // 🔥 Для инициалов ) - Spacer(modifier = Modifier.width(12.dp)) + Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP)) Column(modifier = Modifier.weight(1f)) { Row( @@ -2566,7 +2885,7 @@ fun SwipeableDialogItem( val swipeWidthPx = with(density) { swipeWidthDp.toPx() } // Фиксированная высота элемента (как в DialogItem) - val itemHeight = 80.dp + val itemHeight = TELEGRAM_DIALOG_ROW_HEIGHT // Close when another item starts swiping (like Telegram) LaunchedEffect(isSwipedOpen) { @@ -2716,9 +3035,14 @@ fun SwipeableDialogItem( } // 2. КОНТЕНТ - поверх кнопок, сдвигается при свайпе - // 🔥 rememberUpdatedState чтобы pointerInput всегда вызывал актуальные callbacks + // 🔥 rememberUpdatedState чтобы pointerInput всегда вызывал актуальные значения val currentOnClick by rememberUpdatedState(onClick) 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( modifier = Modifier.fillMaxSize() @@ -2736,7 +3060,7 @@ fun SwipeableDialogItem( ) // Don't handle swipes when drawer is open - if (isDrawerOpen) return@awaitEachGesture + if (currentIsDrawerOpen) return@awaitEachGesture velocityTracker.resetTracking() var totalDragX = 0f @@ -2747,6 +3071,7 @@ fun SwipeableDialogItem( // Phase 1: Determine gesture type (tap / long-press / drag) // Wait up to longPressTimeout; if no up or slop → long press var gestureType = "unknown" + var fingerIsUp = false val result = withTimeoutOrNull(longPressTimeoutMs) { while (true) { @@ -2780,8 +3105,31 @@ fun SwipeableDialogItem( Unit } - // Timeout → long press - if (result == null) gestureType = "longpress" + // Timeout → check if finger lifted during the race window + 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) { "tap" -> { @@ -2811,10 +3159,10 @@ fun SwipeableDialogItem( when { // Horizontal left swipe — reveal action buttons - swipeEnabled && dominated && totalDragX < 0 -> { + currentSwipeEnabled && dominated && totalDragX < 0 -> { passedSlop = true claimed = true - onSwipeStarted() + currentOnSwipeStarted() } // Horizontal right swipe with buttons open — close them dominated && totalDragX > 0 && offsetX != 0f -> { @@ -2828,7 +3176,7 @@ fun SwipeableDialogItem( else -> { if (offsetX != 0f) { offsetX = 0f - onSwipeClosed() + currentOnSwipeClosed() } return@awaitEachGesture } @@ -2847,8 +3195,9 @@ fun SwipeableDialogItem( if (change.changedToUpIgnoreConsumed()) break val delta = change.positionChange() + val curSwipeWidthPx = currentSwipeWidthPx val newOffset = offsetX + delta.x - offsetX = newOffset.coerceIn(-swipeWidthPx, 0f) + offsetX = newOffset.coerceIn(-curSwipeWidthPx, 0f) velocityTracker.addPosition( change.uptimeMillis, change.position @@ -2858,6 +3207,7 @@ fun SwipeableDialogItem( // Phase 3: Snap animation if (claimed) { + val curSwipeWidthPx = currentSwipeWidthPx val velocity = velocityTracker .calculateVelocity() @@ -2865,18 +3215,18 @@ fun SwipeableDialogItem( when { velocity > 150f -> { offsetX = 0f - onSwipeClosed() + currentOnSwipeClosed() } velocity < -300f -> { - offsetX = -swipeWidthPx + offsetX = -curSwipeWidthPx } kotlin.math.abs(offsetX) > - swipeWidthPx / 2 -> { - offsetX = -swipeWidthPx + curSwipeWidthPx / 2 -> { + offsetX = -curSwipeWidthPx } else -> { offsetX = 0f - onSwipeClosed() + currentOnSwipeClosed() } } } @@ -2893,14 +3243,6 @@ fun SwipeableDialogItem( avatarRepository = avatarRepository, 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) else Modifier ) - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding( + horizontal = TELEGRAM_DIALOG_AVATAR_START, + vertical = TELEGRAM_DIALOG_VERTICAL_PADDING + ), verticalAlignment = Alignment.CenterVertically ) { // Avatar container with online indicator - Box(modifier = Modifier.size(56.dp)) { + Box(modifier = Modifier.size(TELEGRAM_DIALOG_AVATAR_SIZE)) { // Avatar if (dialog.isSavedMessages) { Box( @@ -3042,7 +3387,7 @@ fun DialogItemContent( com.rosetta.messenger.ui.components.AvatarImage( publicKey = dialog.opponentKey, avatarRepository = avatarRepository, - size = 56.dp, + size = TELEGRAM_DIALOG_AVATAR_SIZE, isDarkTheme = isDarkTheme, 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 Column(modifier = Modifier.weight(1f)) { @@ -3090,9 +3435,12 @@ fun DialogItemContent( maxLines = 1, 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)) - VerifiedBadge(verified = dialog.verified, size = 16) + VerifiedBadge(verified = if (dialog.verified > 0) dialog.verified else 1, size = 16) } // 🔒 Красная иконка замочка для заблокированных пользователей if (isBlocked) { @@ -3119,7 +3467,9 @@ fun DialogItemContent( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End ) { - // 📁 Для Saved Messages ВСЕГДА показываем синие двойные + // � Скрываем статус доставки когда есть черновик (как в Telegram) + if (dialog.draftText.isNullOrEmpty()) { + // �📁 Для Saved Messages ВСЕГДА показываем синие двойные // галочки (прочитано) if (dialog.isSavedMessages) { Box( @@ -3270,6 +3620,7 @@ fun DialogItemContent( } } } + } // 📝 end if (draftText.isNullOrEmpty) — скрываем статус при наличии черновика val formattedTime = remember(dialog.lastMessageTimestamp) { @@ -3297,8 +3648,29 @@ fun DialogItemContent( // 🔥 Показываем typing индикатор или последнее сообщение if (isTyping) { 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 { - // � Определяем что показывать - attachment или текст + // 📎 Определяем что показывать - attachment или текст val displayText = when { dialog.lastMessageAttachmentType == @@ -3497,13 +3869,16 @@ fun RequestsSection( modifier = Modifier.fillMaxWidth() .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding( + horizontal = TELEGRAM_DIALOG_AVATAR_START, + vertical = TELEGRAM_DIALOG_VERTICAL_PADDING + ), verticalAlignment = Alignment.CenterVertically ) { // Иконка — круглый аватар как Archived Chats в Telegram Box( modifier = - Modifier.size(56.dp) + Modifier.size(TELEGRAM_DIALOG_AVATAR_SIZE) .clip(CircleShape) .background(iconBgColor), 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)) { // Заголовок diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index 4b71452..24ad66f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.data.MessageRepository +import com.rosetta.messenger.data.DraftManager import com.rosetta.messenger.database.BlacklistEntity import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.PacketOnlineSubscribe @@ -40,7 +41,8 @@ data class DialogUiModel( val lastMessageDelivered: Int = 0, // Статус доставки (0=WAITING, 1=DELIVERED, 2=ERROR) val lastMessageRead: Int = 0, // Прочитано (0/1) 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 предотвращает мерцание при быстрых обновлениях + // 📝 Комбинируем с drafts для показа черновиков в списке чатов val chatsState: StateFlow = - combine(_dialogs, _requests, _requestsCount) { dialogs, requests, count -> - ChatsUiState(dialogs, requests, count) + combine(_dialogs, _requests, _requestsCount, DraftManager.drafts) { dialogs, requests, count, drafts -> + // 📝 Обогащаем диалоги черновиками + 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() // 🔥 Игнорируем дублирующиеся состояния .stateIn( @@ -140,7 +150,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio currentAccount = publicKey currentPrivateKey = privateKey - // 🔥 Очищаем устаревшие данные от предыдущего аккаунта + // � Устанавливаем аккаунт для DraftManager (загрузит черновики из SharedPreferences) + DraftManager.setAccount(publicKey) + + // �🔥 Очищаем устаревшие данные от предыдущего аккаунта _dialogs.value = emptyList() _requests.value = emptyList() _requestsCount.value = 0 diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertAvatarLayout.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertAvatarLayout.kt new file mode 100644 index 0000000..6cab029 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertAvatarLayout.kt @@ -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 + ) + } + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertComponents.kt new file mode 100644 index 0000000..bfea362 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertComponents.kt @@ -0,0 +1,1002 @@ +package com.rosetta.messenger.ui.chats.attach + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.view.inputmethod.InputMethodManager +import androidx.camera.core.CameraSelector +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.* +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +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 androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.rosetta.messenger.ui.chats.components.ThumbnailPosition +import com.rosetta.messenger.ui.components.AppleEmojiTextField +import com.rosetta.messenger.ui.icons.TelegramIcons +import com.rosetta.messenger.ui.onboarding.PrimaryBlue +import compose.icons.TablerIcons +import compose.icons.tablericons.ChevronDown +import compose.icons.tablericons.PhotoOff +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +// ═══════════════════════════════════════════════════════════════ +// Media Grid +// ═══════════════════════════════════════════════════════════════ + +@Composable +internal fun MediaGrid( + mediaItems: List, + selectedItemOrder: List, + showCameraItem: Boolean = true, + gridState: LazyGridState = rememberLazyGridState(), + onCameraClick: () -> Unit, + onItemClick: (MediaItem, ThumbnailPosition) -> Unit, + onItemCheckClick: (MediaItem) -> Unit, + onItemLongClick: (MediaItem) -> Unit, + isDarkTheme: Boolean, + modifier: Modifier = Modifier +) { + val selectionIndexById = + remember(selectedItemOrder) { + selectedItemOrder.withIndex().associate { (index, id) -> id to (index + 1) } + } + + LazyVerticalGrid( + columns = GridCells.Fixed(3), + state = gridState, + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (showCameraItem) { + item(key = "camera_button") { + CameraGridItem( + onClick = onCameraClick, + isDarkTheme = isDarkTheme + ) + } + } + + items( + items = mediaItems, + key = { it.id } + ) { item -> + val selectionIndex = selectionIndexById[item.id] ?: 0 + MediaGridItem( + item = item, + isSelected = selectionIndex > 0, + selectionIndex = selectionIndex, + onContentClick = { position -> onItemClick(item, position) }, + onCheckClick = { onItemCheckClick(item) }, + onLongClick = { onItemLongClick(item) }, + isDarkTheme = isDarkTheme + ) + } + } +} + +// ═══════════════════════════════════════════════════════════════ +// Camera Grid Item — live CameraX preview +// ═══════════════════════════════════════════════════════════════ + +@Composable +internal fun CameraGridItem( + onClick: () -> Unit, + isDarkTheme: Boolean +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) + + val iconScale = remember { Animatable(0f) } + LaunchedEffect(Unit) { + iconScale.animateTo( + targetValue = 1f, + animationSpec = spring(dampingRatio = 0.5f, stiffness = 400f) + ) + } + + var hasCameraPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission( + context, Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + ) + } + + LaunchedEffect(Unit) { + hasCameraPermission = ContextCompat.checkSelfPermission( + context, Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + } + + Box( + modifier = Modifier + .aspectRatio(1f) + .clip(RoundedCornerShape(4.dp)) + .background(backgroundColor) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + if (hasCameraPermission) { + AndroidView( + factory = { ctx -> + val previewView = PreviewView(ctx).apply { + scaleType = PreviewView.ScaleType.FILL_CENTER + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + } + + val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) + cameraProviderFuture.addListener({ + try { + val cameraProvider = cameraProviderFuture.get() + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview + ) + } catch (_: Exception) { + // Camera init failed + } + }, ContextCompat.getMainExecutor(ctx)) + + previewView + }, + modifier = Modifier.fillMaxSize() + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.3f)), + contentAlignment = Alignment.Center + ) { + Icon( + painter = TelegramIcons.Camera, + contentDescription = "Camera", + tint = Color.White, + modifier = Modifier + .size(32.dp) + .graphicsLayer { + scaleX = iconScale.value + scaleY = iconScale.value + } + ) + } + } else { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = TelegramIcons.Camera, + contentDescription = "Camera", + tint = PrimaryBlue, + modifier = Modifier + .size(40.dp) + .graphicsLayer { + scaleX = iconScale.value + scaleY = iconScale.value + } + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Camera", + color = PrimaryBlue, + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ) + } + } + } +} + +// ═══════════════════════════════════════════════════════════════ +// Media Grid Item — single photo/video cell +// ═══════════════════════════════════════════════════════════════ + +@Composable +internal fun MediaGridItem( + item: MediaItem, + isSelected: Boolean, + selectionIndex: Int, + onContentClick: (ThumbnailPosition) -> Unit, + onCheckClick: () -> Unit, + onLongClick: () -> Unit, + isDarkTheme: Boolean +) { + val context = LocalContext.current + val selectedTransition = updateTransition(targetState = isSelected, label = "mediaSelection") + val itemScale by selectedTransition.animateFloat( + transitionSpec = { tween(durationMillis = 200, easing = FastOutSlowInEasing) }, + label = "itemScale" + ) { selected -> if (selected) 0.787f else 1f } + + val tileBackgroundColor by animateColorAsState( + targetValue = if (isSelected) { + if (isDarkTheme) Color(0xFF2F2F31) else Color(0xFFE8EDF5) + } else Color.Transparent, + animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing), + label = "tileBackgroundColor" + ) + val checkBackgroundColor by animateColorAsState( + targetValue = if (isSelected) PrimaryBlue else Color(0xCCFFFFFF), + animationSpec = tween(durationMillis = 170, easing = FastOutSlowInEasing), + label = "checkBackgroundColor" + ) + val checkBorderColor by animateColorAsState( + targetValue = if (isSelected) PrimaryBlue else Color(0x80B0B0B0), + animationSpec = tween(durationMillis = 170, easing = FastOutSlowInEasing), + label = "checkBorderColor" + ) + val checkContentAlpha by animateFloatAsState( + targetValue = if (isSelected) 1f else 0f, + animationSpec = tween(durationMillis = 140, easing = FastOutSlowInEasing), + label = "checkContentAlpha" + ) + val checkScale = remember { Animatable(1f) } + LaunchedEffect(isSelected) { + if (isSelected) { + checkScale.snapTo(0.72f) + checkScale.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ) + ) + } + } + + var itemPosition by remember { mutableStateOf(null) } + + Box( + modifier = Modifier + .aspectRatio(1f) + .clip(RoundedCornerShape(4.dp)) + .background(tileBackgroundColor) + .onGloballyPositioned { coordinates -> + val positionInWindow = coordinates.positionInWindow() + itemPosition = ThumbnailPosition( + x = positionInWindow.x, + y = positionInWindow.y, + width = coordinates.size.width.toFloat(), + height = coordinates.size.height.toFloat(), + cornerRadius = 4f + ) + } + ) { + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTapGestures( + onTap = { itemPosition?.let { onContentClick(it) } }, + onLongPress = { onLongClick() } + ) + } + .graphicsLayer { + scaleX = itemScale + scaleY = itemScale + } + .clip(RoundedCornerShape(4.dp)) + ) { + val imageRequest = remember(item.uri) { + ImageRequest.Builder(context) + .data(item.uri) + .crossfade(false) + .build() + } + AsyncImage( + model = imageRequest, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + + if (item.isVideo && item.duration > 0) { + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(4.dp) + .background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(4.dp)) + .padding(horizontal = 4.dp, vertical = 2.dp) + ) { + Text( + text = formatDuration(item.duration), + color = Color.White, + fontSize = 11.sp, + fontWeight = FontWeight.Medium + ) + } + } + } + + // Selection checkbox + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(6.dp) + .size(24.dp) + .graphicsLayer { + scaleX = checkScale.value + scaleY = checkScale.value + } + .clip(CircleShape) + .background(checkBackgroundColor) + .border(width = 2.dp, color = checkBorderColor, shape = CircleShape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onCheckClick + ), + contentAlignment = Alignment.Center + ) { + if (selectionIndex > 0) { + Text( + text = selectionIndex.toString(), + color = Color.White.copy(alpha = checkContentAlpha), + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + } + } + } +} + +// ═══════════════════════════════════════════════════════════════ +// Album Dropdown Menu +// ═══════════════════════════════════════════════════════════════ + +@Composable +internal fun AlbumDropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + albums: List, + selectedAlbumId: Long, + onAlbumSelected: (Long) -> Unit, + isDarkTheme: Boolean, + textColor: Color, + secondaryTextColor: Color +) { + val menuBg = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White + + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + modifier = Modifier + .background(menuBg) + .widthIn(min = 220.dp, max = 300.dp) + ) { + albums.forEach { album -> + val isSelected = album.id == selectedAlbumId + val coverUri = album.items.firstOrNull()?.uri + + DropdownMenuItem( + text = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .size(32.dp) + .clip(RoundedCornerShape(4.dp)) + .background(if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE5E5EA)) + ) { + if (coverUri != null) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(coverUri) + .crossfade(false) + .size(80) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (album.isAllMedia) "All media" else album.name, + color = textColor, + fontSize = 15.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1 + ) + Text( + text = album.items.size.toString(), + color = secondaryTextColor, + fontSize = 12.sp, + maxLines = 1 + ) + } + + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(18.dp) + ) + } + } + }, + onClick = { onAlbumSelected(album.id) }, + modifier = Modifier.height(48.dp) + ) + } + } +} + +// ═══════════════════════════════════════════════════════════════ +// Tab Bar — replaces QuickActionsRow with active tab highlight +// ═══════════════════════════════════════════════════════════════ + +@Composable +internal fun AttachTabBar( + currentTab: AttachAlertTab, + onTabSelected: (AttachAlertTab) -> Unit, + isDarkTheme: Boolean, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(start = 28.dp, end = 28.dp, top = 8.dp, bottom = 12.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.Bottom + ) { + TabButton( + icon = TelegramIcons.Photos, + contentDescription = "Gallery", + label = "Gallery", + isActive = currentTab == AttachAlertTab.PHOTO, + isDarkTheme = isDarkTheme, + animationDelayMs = 0, + onClick = { onTabSelected(AttachAlertTab.PHOTO) } + ) + TabButton( + icon = TelegramIcons.File, + contentDescription = "File", + label = "File", + isActive = currentTab == AttachAlertTab.FILE, + isDarkTheme = isDarkTheme, + animationDelayMs = 32, + onClick = { onTabSelected(AttachAlertTab.FILE) } + ) + TabButton( + icon = TelegramIcons.Contact, + contentDescription = "Avatar", + label = "Avatar", + isActive = currentTab == AttachAlertTab.AVATAR, + isDarkTheme = isDarkTheme, + animationDelayMs = 64, + onClick = { onTabSelected(AttachAlertTab.AVATAR) } + ) + } +} + +@Composable +private fun TabButton( + icon: androidx.compose.ui.graphics.painter.Painter, + contentDescription: String, + label: String, + isDarkTheme: Boolean, + isActive: Boolean = false, + animationDelayMs: Int, + onClick: () -> Unit +) { + val scale = remember { Animatable(0f) } + val alpha = remember { Animatable(0f) } + val easeOut = remember { CubicBezierEasing(0f, 0f, 0.58f, 1f) } + val easeIn = remember { CubicBezierEasing(0.42f, 0f, 1f, 1f) } + LaunchedEffect(Unit) { + delay(animationDelayMs.toLong()) + alpha.snapTo(0f) + scale.snapTo(0f) + coroutineScope { + launch { alpha.animateTo(1f, animationSpec = tween(200, easing = easeOut)) } + launch { + scale.animateTo(1.1f, animationSpec = tween(200, easing = easeOut)) + scale.animateTo(1f, animationSpec = tween(100, easing = easeIn)) + } + } + } + + Column( + modifier = Modifier.width(72.dp).graphicsLayer { this.alpha = alpha.value }, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(56.dp) + .graphicsLayer { + scaleX = scale.value + scaleY = scale.value + } + .clip(CircleShape) + .background(PrimaryBlue) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = icon, + contentDescription = contentDescription, + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = label, + fontSize = 18.sp * 0.72f, + fontWeight = FontWeight.Medium, + color = when { + isActive -> PrimaryBlue + isDarkTheme -> Color(0xFF9AA1AB) + else -> Color(0xFF7A8190) + }, + maxLines = 1 + ) + } +} + +// ═══════════════════════════════════════════════════════════════ +// Collapsed Header — drag handle + selection text +// ═══════════════════════════════════════════════════════════════ + +@Composable +internal fun CollapsedHeader( + selectionHeaderText: String, + selectionHeaderHeightDp: androidx.compose.ui.unit.Dp, + selectionHeaderAlpha: Float, + handleAlpha: Float, + collapsedHeaderAlpha: Float, + textColor: Color, + secondaryTextColor: Color +) { + Column( + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { alpha = collapsedHeaderAlpha } + ) { + // Drag handle + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = Modifier + .width(36.dp) + .height(4.dp) + .graphicsLayer { alpha = handleAlpha } + .clip(RoundedCornerShape(2.dp)) + .background(secondaryTextColor.copy(alpha = 0.3f)) + ) + Spacer(modifier = Modifier.height(6.dp)) + } + + // "N photo(s) selected" — height derived from sheet growth + Box( + modifier = Modifier + .fillMaxWidth() + .height(selectionHeaderHeightDp) + .graphicsLayer { + alpha = selectionHeaderAlpha + clip = true + }, + contentAlignment = Alignment.CenterStart + ) { + if (selectionHeaderAlpha > 0.01f) { + Text( + text = selectionHeaderText, + color = textColor, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + modifier = Modifier.padding(start = 23.dp) + ) + } + } + } +} + +// ═══════════════════════════════════════════════════════════════ +// Expanded Header — back button + album dropdown / selection count +// ═══════════════════════════════════════════════════════════════ + +@Composable +internal fun ExpandedHeader( + fullScreenHeaderProgress: Float, + statusBarTopPadding: androidx.compose.ui.unit.Dp, + selectedCount: Int, + currentAlbumTitle: String, + canSwitchAlbums: Boolean, + showAlbumMenu: Boolean, + onShowAlbumMenu: () -> Unit, + onDismissAlbumMenu: () -> Unit, + albums: List, + selectedAlbumId: Long, + onAlbumSelected: (Long) -> Unit, + onClose: () -> Unit, + isDarkTheme: Boolean, + textColor: Color, + secondaryTextColor: Color, + density: androidx.compose.ui.unit.Density +) { + val fullScreenHeaderHeight = (statusBarTopPadding + 52.dp) * fullScreenHeaderProgress + Box( + modifier = Modifier + .fillMaxWidth() + .height(fullScreenHeaderHeight) + .graphicsLayer { clip = true } + ) { + if (fullScreenHeaderProgress > 0.001f) { + val headerTranslationY = with(density) { (1f - fullScreenHeaderProgress) * (-14).dp.toPx() } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = statusBarTopPadding, start = 4.dp, end = 8.dp) + .graphicsLayer { + alpha = fullScreenHeaderProgress + translationY = headerTranslationY + }, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = onClose, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Close gallery", + tint = textColor + ) + } + + Box(modifier = Modifier.weight(1f)) { + AnimatedContent( + targetState = selectedCount > 0, + transitionSpec = { + fadeIn(tween(160)) togetherWith fadeOut(tween(160)) + }, + label = "expanded_header_title" + ) { hasSelection -> + if (hasSelection) { + Text( + text = when (selectedCount) { + 1 -> "1 photo selected" + else -> "$selectedCount photos selected" + }, + color = textColor, + fontSize = 19.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1 + ) + } else { + Row( + modifier = Modifier + .clip(RoundedCornerShape(18.dp)) + .clickable( + enabled = canSwitchAlbums, + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { if (canSwitchAlbums) onShowAlbumMenu() } + .padding(horizontal = 6.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = currentAlbumTitle, + color = textColor, + fontSize = 19.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1 + ) + if (canSwitchAlbums) { + Spacer(modifier = Modifier.width(2.dp)) + Icon( + imageVector = TablerIcons.ChevronDown, + contentDescription = null, + tint = textColor.copy(alpha = 0.7f), + modifier = Modifier.size(18.dp) + ) + } + } + } + } + + AlbumDropdownMenu( + expanded = showAlbumMenu && canSwitchAlbums && fullScreenHeaderProgress > 0.5f, + onDismissRequest = onDismissAlbumMenu, + albums = albums, + selectedAlbumId = selectedAlbumId, + onAlbumSelected = { albumId -> + onAlbumSelected(albumId) + onDismissAlbumMenu() + }, + isDarkTheme = isDarkTheme, + textColor = textColor, + secondaryTextColor = secondaryTextColor + ) + } + } + } + } +} + +// ═══════════════════════════════════════════════════════════════ +// Caption Bar +// ═══════════════════════════════════════════════════════════════ + +@Composable +internal fun CaptionBar( + captionBarProgress: Float, + captionText: String, + onCaptionChange: (String) -> Unit, + isCaptionKeyboardVisible: Boolean, + onToggleKeyboard: () -> Unit, + onFocusCaption: () -> Unit, + onViewCreated: (com.rosetta.messenger.ui.components.AppleEmojiEditTextView) -> Unit, + isDarkTheme: Boolean, + density: androidx.compose.ui.unit.Density +) { + val captionBarHeight = 52.dp * captionBarProgress + Box( + modifier = Modifier + .fillMaxWidth() + .height(captionBarHeight) + .graphicsLayer { clip = true } + ) { + if (captionBarProgress > 0.001f) { + val captionOffsetPx = with(density) { (1f - captionBarProgress) * 18.dp.toPx() } + Column( + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { + alpha = captionBarProgress + translationY = captionOffsetPx + } + ) { + // Thin divider + Box( + modifier = Modifier + .fillMaxWidth() + .height(0.5.dp) + .background( + if (isDarkTheme) Color.White.copy(alpha = 0.08f) + else Color.Black.copy(alpha = 0.12f) + ) + ) + // Flat caption row + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 6.dp, end = 84.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + val captionIconPainter = + if (isCaptionKeyboardVisible) TelegramIcons.Keyboard + else TelegramIcons.Smile + Icon( + painter = captionIconPainter, + contentDescription = "Caption keyboard toggle", + tint = if (isDarkTheme) Color.White.copy(alpha = 0.55f) else Color.Gray, + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onToggleKeyboard() } + .padding(4.dp) + ) + + Box( + modifier = Modifier + .weight(1f) + .heightIn(min = 28.dp, max = 120.dp), + contentAlignment = Alignment.CenterStart + ) { + AppleEmojiTextField( + value = captionText, + onValueChange = onCaptionChange, + textColor = if (isDarkTheme) Color.White else Color.Black, + textSize = 16f, + hint = "Add a caption...", + hintColor = if (isDarkTheme) Color.White.copy(alpha = 0.35f) + else Color.Gray.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth(), + requestFocus = false, + onViewCreated = onViewCreated, + onFocusChanged = { hasFocus -> + // When EditText gets native touch → gains focus → + // trigger FLAG_ALT_FOCUSABLE_IM management in ChatAttachAlert. + // This replaces the previous .clickable on parent Box which + // swallowed touch events and prevented keyboard from appearing. + android.util.Log.d("AttachAlert", "CaptionBar EditText onFocusChanged: hasFocus=$hasFocus") + if (hasFocus) onFocusCaption() + } + ) + } + } + } + } + } +} + +// ═══════════════════════════════════════════════════════════════ +// Floating Send Button +// ═══════════════════════════════════════════════════════════════ + +@Composable +internal fun FloatingSendButton( + selectedCount: Int, + sendButtonProgress: Float, + backgroundColor: Color, + onSend: () -> Unit, + modifier: Modifier = Modifier +) { + if (sendButtonProgress > 0.001f) { + val sendScale = 0.2f + 0.8f * sendButtonProgress + Box( + modifier = modifier + .padding(end = 14.dp, bottom = 8.dp) + .graphicsLayer { + scaleX = sendScale + scaleY = sendScale + alpha = sendButtonProgress + }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(56.dp) + .shadow(elevation = 6.dp, shape = CircleShape, clip = false) + .clip(CircleShape) + .background(PrimaryBlue) + .clickable { onSend() }, + contentAlignment = Alignment.Center + ) { + Icon( + painter = TelegramIcons.Send, + contentDescription = "Send", + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + if (selectedCount > 0) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = 4.dp, y = (-2).dp) + .sizeIn(minWidth = 22.dp, minHeight = 22.dp) + .border(width = 2.dp, color = backgroundColor, shape = CircleShape) + .clip(CircleShape) + .background(PrimaryBlue) + .padding(horizontal = 4.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = selectedCount.toString(), + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + maxLines = 1 + ) + } + } + } + } +} + +// ═══════════════════════════════════════════════════════════════ +// Permission Request View +// ═══════════════════════════════════════════════════════════════ + +@Composable +internal fun PermissionRequestView( + isDarkTheme: Boolean, + textColor: Color, + secondaryTextColor: Color, + onRequestPermission: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + TablerIcons.PhotoOff, + contentDescription = null, + tint = secondaryTextColor, + modifier = Modifier.size(64.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Access to gallery", + color = textColor, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Allow access to your photos and videos to share them in chat", + color = secondaryTextColor, + fontSize = 14.sp, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = onRequestPermission, + colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), + shape = RoundedCornerShape(12.dp) + ) { + Text("Allow Access", color = Color.White) + } + } + } +} + +// ═══════════════════════════════════════════════════════════════ +// Utilities +// ═══════════════════════════════════════════════════════════════ + +internal fun formatDuration(durationMs: Long): String { + val totalSeconds = durationMs / 1000 + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return "%d:%02d".format(minutes, seconds) +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertFileLayout.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertFileLayout.kt new file mode 100644 index 0000000..5c54e8b --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertFileLayout.kt @@ -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 + ) + } + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertPhotoLayout.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertPhotoLayout.kt new file mode 100644 index 0000000..d1a824c --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertPhotoLayout.kt @@ -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 + ) + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertState.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertState.kt new file mode 100644 index 0000000..223b7ca --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertState.kt @@ -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, + val isAllMedia: Boolean = false +) + +data class MediaPickerData( + val items: List, + val albums: List +) + +/** + * 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 = emptyList(), + val albums: List = emptyList(), + val selectedAlbumId: Long = ALL_MEDIA_ALBUM_ID, + val selectedItemOrder: List = 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 + 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. diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertViewModel.kt new file mode 100644 index 0000000..0b949b9 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertViewModel.kt @@ -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 = _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 { + 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 + ) + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt new file mode 100644 index 0000000..b860807 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt @@ -0,0 +1,1234 @@ +package com.rosetta.messenger.ui.chats.attach + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.view.inputmethod.InputMethodManager +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.* +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.* +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import androidx.core.content.ContextCompat +import androidx.core.view.WindowCompat +import androidx.lifecycle.viewmodel.compose.viewModel +import com.rosetta.messenger.ui.chats.components.ImageEditorScreen +import com.rosetta.messenger.ui.chats.components.PhotoPreviewWithCaptionScreen +import com.rosetta.messenger.ui.chats.components.ThumbnailPosition +import com.rosetta.messenger.ui.onboarding.PrimaryBlue +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import android.net.Uri +import android.util.Log +import android.view.View +import android.view.WindowManager +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +// ═══════════════════════════════════════════════════════════ +// Debug log buffer — captures AttachAlert logs in-memory +// ═══════════════════════════════════════════════════════════ +private object AttachAlertDebugLog { + private val _entries = mutableListOf() + val entries: List get() = _entries.toList() + private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) + + fun log(tag: String, msg: String) { + val time = timeFormat.format(Date()) + val entry = "$time [$tag] $msg" + synchronized(_entries) { + _entries.add(entry) + if (_entries.size > 200) _entries.removeAt(0) // keep last 200 + } + Log.d(tag, msg) // also send to Logcat + } + + fun warn(tag: String, msg: String, t: Throwable? = null) { + val time = timeFormat.format(Date()) + val entry = "$time [W/$tag] $msg${if (t != null) " | ${t.message}" else ""}" + synchronized(_entries) { + _entries.add(entry) + if (_entries.size > 200) _entries.removeAt(0) + } + if (t != null) Log.w(tag, msg, t) else Log.w(tag, msg) + } + + fun clear() = synchronized(_entries) { _entries.clear() } +} + +/** + * Telegram-style FLAG_ALT_FOCUSABLE_IM toggle on the Popup window. + * Synchronous — no recomposition dependency. + * + * When [imeFocusable] is false, FLAG_ALT_FOCUSABLE_IM is set so the Popup + * doesn't interact with IME (keyboard stays with the Activity behind). + * When true, the flag is cleared so the Popup receives keyboard input. + */ +private fun updatePopupImeFocusable(rootView: View, imeFocusable: Boolean) { + val params = rootView.layoutParams as? WindowManager.LayoutParams ?: run { + AttachAlertDebugLog.warn("AttachAlert", "updatePopupImeFocusable: layoutParams is not WM.LayoutParams!") + return + } + val wm = rootView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + if (imeFocusable) { + params.flags = params.flags and WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM.inv() + } else { + params.flags = params.flags or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM + } + // Don't change softInputMode on the Popup window. + // Compose Popup + enableEdgeToEdge() handles keyboard insets via WindowInsets + // (no physical window resize). Manually changing to ADJUST_RESIZE causes + // double-subtraction: system shrinks window AND we subtract imeInsets. + try { + wm.updateViewLayout(rootView, params) + AttachAlertDebugLog.log("AttachAlert", "updatePopupImeFocusable($imeFocusable) — flags updated OK") + } catch (e: IllegalArgumentException) { + AttachAlertDebugLog.warn("AttachAlert", "updatePopupImeFocusable: view not attached", e) + } +} + +/** + * Telegram-style attach alert (media picker bottom sheet). + * + * Tab-based architecture with Photo, File, and Avatar tabs. + * Uses Popup for rendering over the keyboard, ViewModel for state management, + * and smooth animations for all transitions. + * + * Signature is backwards-compatible with the old MediaPickerBottomSheet. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatAttachAlert( + isVisible: Boolean, + onDismiss: () -> Unit, + isDarkTheme: Boolean, + onMediaSelected: (List, String) -> Unit, + onMediaSelectedWithCaption: ((MediaItem, String) -> Unit)? = null, + onOpenCamera: () -> Unit = {}, + onOpenFilePicker: () -> Unit = {}, + onAvatarClick: () -> Unit = {}, + currentUserPublicKey: String = "", + maxSelection: Int = 10, + recipientName: String? = null, + viewModel: AttachAlertViewModel = viewModel() +) { + val context = LocalContext.current + val density = LocalDensity.current + val imeInsets = WindowInsets.ime + val focusManager = LocalFocusManager.current + + val state by viewModel.uiState.collectAsState() + + // ═══════════════════════════════════════════════════════════ + // Local UI state (animation-related, not in ViewModel) + // ═══════════════════════════════════════════════════════════ + + var captionEditTextView by remember { mutableStateOf(null) } + var thumbnailPosition by remember { mutableStateOf(null) } + var pendingPhotoUri by remember { mutableStateOf(null) } + var previewPhotoUri by remember { mutableStateOf(null) } + var photoCaption by remember { mutableStateOf("") } + var isExpanded by remember { mutableStateOf(false) } + var isClosing by remember { mutableStateOf(false) } + var shouldShow by remember { mutableStateOf(false) } + var closeAction by remember { mutableStateOf<(() -> Unit)?>(null) } + var showDiscardSelectionDialog by remember { mutableStateOf(false) } + var pendingDismissAfterConfirm by remember { mutableStateOf<(() -> Unit)?>(null) } + var hadSelection by remember { mutableStateOf(false) } + var isCaptionKeyboardVisible by remember { mutableStateOf(false) } + var showAlbumMenu by remember { mutableStateOf(false) } + var imeBottomInsetPx by remember { mutableIntStateOf(0) } + + // Keyboard-aware: Telegram FLAG_ALT_FOCUSABLE_IM pattern. + // Popup is always focusable (for touch + back), but FLAG_ALT_FOCUSABLE_IM + // prevents IME interaction until caption is tapped. + var captionInputActive by remember { mutableStateOf(false) } + var pendingCaptionFocus by remember { mutableStateOf(false) } + var popupRootView by remember { mutableStateOf(null) } + val activity = context as? Activity + val savedSoftInputMode = remember { mutableIntStateOf( + activity?.window?.attributes?.softInputMode + ?: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE + ) } + + // ═══════════════════════════════════════════════════════════ + // Keyboard helpers + // ═══════════════════════════════════════════════════════════ + + fun hideKeyboard() { + AttachAlertDebugLog.log("AttachAlert", "hideKeyboard() called, captionInputActive=$captionInputActive, shouldShow=$shouldShow, isClosing=$isClosing") + pendingCaptionFocus = false + captionInputActive = false + focusManager.clearFocus(force = true) + captionEditTextView?.clearFocus() + + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + // Hide keyboard using the caption EditText's window token (Popup window) + captionEditTextView?.windowToken?.let { token -> + imm.hideSoftInputFromWindow(token, 0) + } + + // Restore FLAG_ALT_FOCUSABLE_IM on Popup window (non-IME mode) + popupRootView?.let { updatePopupImeFocusable(it, false) } + } + + fun resetPickerCaptionInput() { + viewModel.updateCaption("") + captionEditTextView?.clearFocus() + } + + // Telegram pattern: synchronously clear FLAG_ALT_FOCUSABLE_IM, defer focus to LaunchedEffect + fun focusCaptionInput() { + AttachAlertDebugLog.log("AttachAlert", "focusCaptionInput() called, popupRootView=${popupRootView != null}, editTextView=${captionEditTextView != null}") + captionInputActive = true + // Synchronously update Popup window flags (like Telegram's setFocusable(true)) + popupRootView?.let { + updatePopupImeFocusable(it, true) + } ?: AttachAlertDebugLog.warn("AttachAlert", "popupRootView is NULL — cannot update window flags!") + pendingCaptionFocus = true + } + + // ═══════════════════════════════════════════════════════════ + // Layout metrics + // ═══════════════════════════════════════════════════════════ + + val configuration = LocalConfiguration.current + val screenHeight = configuration.screenHeightDp.dp + val screenHeightPx = with(density) { screenHeight.toPx() } + val navigationBarInsetPx = WindowInsets.navigationBars.getBottom(density).toFloat() + val statusBarInsetPx = WindowInsets.statusBars.getTop(density).toFloat() + + val collapsedHeightPx = screenHeightPx * 0.72f + val expandedHeightPx = + (screenHeightPx + statusBarInsetPx).coerceAtLeast(screenHeightPx * 0.95f) + val minHeightPx = screenHeightPx * 0.2f + val captionLiftEasing = CubicBezierEasing(0f, 0f, 0.2f, 1f) + val telegramButtonsEasing = CubicBezierEasing(0f, 0f, 0.58f, 1f) + val collapsedStateHeightPx = collapsedHeightPx.coerceAtMost(expandedHeightPx) + val selectionHeaderExtraPx = with(density) { 26.dp.toPx() } + + val captionBarProgress by animateFloatAsState( + targetValue = if (state.selectedItemOrder.isNotEmpty()) 1f else 0f, + animationSpec = tween(180, easing = captionLiftEasing), + label = "caption_bar_progress" + ) + + val sheetHeightPx = remember { Animatable(collapsedHeightPx) } + val animationScope = rememberCoroutineScope() + + // ═══════════════════════════════════════════════════════════ + // Permission handling + // ═══════════════════════════════════════════════════════════ + + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + val granted = permissions.values.all { it } + viewModel.setPermissionGranted(granted) + if (granted) { + viewModel.loadMedia(context) + } + } + + fun requestPermissions() { + val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + arrayOf( + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VIDEO, + Manifest.permission.CAMERA + ) + } else { + arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.CAMERA + ) + } + permissionLauncher.launch(permissions) + } + + // ═══════════════════════════════════════════════════════════ + // Animations + // ═══════════════════════════════════════════════════════════ + + val animatedOffset by animateFloatAsState( + targetValue = if (shouldShow && !isClosing) 0f else 1f, + animationSpec = if (isClosing) { + tween(200, easing = FastOutSlowInEasing) + } else { + tween(280, easing = FastOutSlowInEasing) + }, + finishedListener = { + AttachAlertDebugLog.log("AttachAlert", "animatedOffset finished: isClosing=$isClosing, shouldShow=$shouldShow") + if (isClosing) { + AttachAlertDebugLog.log("AttachAlert", "CLOSE animation complete — hiding popup, restoring softInputMode") + isClosing = false + shouldShow = false + isExpanded = false + captionInputActive = false + pendingCaptionFocus = false + // Restore original softInputMode + activity?.window?.setSoftInputMode(savedSoftInputMode.intValue) + val action = closeAction + closeAction = null + animationScope.launch { + sheetHeightPx.snapTo(collapsedHeightPx) + } + action?.invoke() + onDismiss() + } + }, + label = "sheet_slide" + ) + + val scrimAlpha by animateFloatAsState( + targetValue = if (shouldShow && !isClosing) { + val expandRange = (expandedHeightPx - collapsedStateHeightPx).coerceAtLeast(1f) + val expandProgress = (sheetHeightPx.value - collapsedStateHeightPx) / expandRange + 0.25f + 0.15f * expandProgress.coerceIn(0f, 1f) + } else 0f, + animationSpec = tween( + durationMillis = if (isClosing) 200 else 300, + easing = FastOutSlowInEasing + ), + label = "scrim_fade" + ) + + // ═══════════════════════════════════════════════════════════ + // SoftInputMode safety net — restore on composable disposal + // ═══════════════════════════════════════════════════════════ + + DisposableEffect(Unit) { + onDispose { + activity?.window?.setSoftInputMode(savedSoftInputMode.intValue) + } + } + + // ═══════════════════════════════════════════════════════════ + // Sheet show/hide logic + // ═══════════════════════════════════════════════════════════ + + val showSheet = isVisible && pendingPhotoUri == null && previewPhotoUri == null + val mediaGridState = rememberLazyGridState() + + // Check permissions and load media on show + LaunchedEffect(isVisible) { + if (isVisible) { + viewModel.onShow() + resetPickerCaptionInput() + photoCaption = "" + + val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + arrayOf( + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VIDEO, + Manifest.permission.CAMERA + ) + } else { + arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.CAMERA + ) + } + + val hasPermission = permissions.all { + ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + } + viewModel.setPermissionGranted(hasPermission) + + if (hasPermission) { + viewModel.loadMedia(context) + } else { + requestPermissions() + } + } + } + + LaunchedEffect(showSheet) { + if (showSheet && state.editingItem == null) { + // Telegram pattern: set ADJUST_NOTHING on Activity before showing popup + // This prevents the system from resizing the layout when focus changes + activity?.window?.let { win -> + savedSoftInputMode.intValue = win.attributes.softInputMode + win.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) + } + captionInputActive = false + // FLAG_ALT_FOCUSABLE_IM will be set via SideEffect inside the Popup + shouldShow = true + isClosing = false + showAlbumMenu = false + sheetHeightPx.snapTo(collapsedStateHeightPx) + } + } + + // Grow sheet when items selected (collapsed only) + LaunchedEffect(showSheet, state.selectedItemOrder.isNotEmpty(), isExpanded, isClosing, state.editingItem) { + if (showSheet && state.editingItem == null && !isClosing && !isExpanded) { + val targetHeight = if (state.selectedItemOrder.isNotEmpty()) { + collapsedStateHeightPx + selectionHeaderExtraPx + } else { + collapsedStateHeightPx + } + if (kotlin.math.abs(sheetHeightPx.value - targetHeight) > 1f) { + sheetHeightPx.stop() + sheetHeightPx.animateTo( + targetHeight, + animationSpec = tween(180, easing = captionLiftEasing) + ) + } + } + } + + // Track IME insets — only treat as "caption keyboard" when caption is active + // (i.e., FLAG_ALT_FOCUSABLE_IM is cleared and keyboard belongs to caption, not chat) + LaunchedEffect(showSheet, captionInputActive) { + AttachAlertDebugLog.log("AttachAlert", "IME-tracker LaunchedEffect: showSheet=$showSheet, captionInputActive=$captionInputActive") + if (showSheet) { + snapshotFlow { imeInsets.getBottom(density) } + .collect { imeBottom -> + val prevIme = imeBottomInsetPx + imeBottomInsetPx = imeBottom + val newCaptionKbVisible = imeBottom > 0 && captionInputActive + if (prevIme != imeBottom || isCaptionKeyboardVisible != newCaptionKbVisible) { + AttachAlertDebugLog.log("AttachAlert", "IME inset: $prevIme → $imeBottom, captionActive=$captionInputActive, captionKbVisible: $isCaptionKeyboardVisible → $newCaptionKbVisible, shouldShow=$shouldShow, isClosing=$isClosing") + } + isCaptionKeyboardVisible = newCaptionKbVisible + } + } else { + imeBottomInsetPx = 0 + isCaptionKeyboardVisible = false + } + } + + // Deferred caption focus: FLAG_ALT_FOCUSABLE_IM was cleared synchronously via + // updatePopupImeFocusable(), now we just need a small delay for the WindowManager + // to process the flag change, then request focus + show keyboard. + // Mirrors Telegram's makeFocusable() double-runOnUIThread pattern. + LaunchedEffect(captionInputActive, pendingCaptionFocus) { + if (captionInputActive && pendingCaptionFocus) { + AttachAlertDebugLog.log("AttachAlert", "LaunchedEffect: starting deferred caption focus") + kotlinx.coroutines.delay(80) // Wait for WindowManager to process flag change + captionEditTextView?.let { view -> + AttachAlertDebugLog.log("AttachAlert", "LaunchedEffect: view found, windowToken=${view.windowToken != null}, isAttached=${view.isAttachedToWindow}") + view.post { + view.requestFocus() + AttachAlertDebugLog.log("AttachAlert", "LaunchedEffect: requestFocus() called, hasFocus=${view.hasFocus()}") + view.postDelayed({ + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) + as InputMethodManager + // Use SHOW_FORCED instead of SHOW_IMPLICIT — IMPLICIT can be ignored + // by Android 13+ when EditText didn't receive a native touch event. + val shown = imm.showSoftInput(view, InputMethodManager.SHOW_FORCED) + AttachAlertDebugLog.log("AttachAlert", "LaunchedEffect: showSoftInput(SHOW_FORCED) result=$shown") + if (!shown) { + // Fallback: try WindowInsetsController on Android 11+ + AttachAlertDebugLog.log("AttachAlert", "LaunchedEffect: SHOW_FORCED failed, trying WindowInsetsController") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + view.windowInsetsController?.show( + android.view.WindowInsets.Type.ime() + ) + } + } + }, 60) + } + } ?: AttachAlertDebugLog.warn("AttachAlert", "LaunchedEffect: captionEditTextView is NULL!") + pendingCaptionFocus = false // Reset AFTER focus attempt, not before + } + } + + // Scroll grid on album change + LaunchedEffect(state.selectedAlbumId) { + mediaGridState.scrollToItem(0) + } + + // Clear keyboard when selection cleared + LaunchedEffect(state.selectedItemOrder.size) { + if (state.selectedItemOrder.isNotEmpty()) { + hadSelection = true + } else if (hadSelection) { + hadSelection = false + hideKeyboard() + } + } + + // Hide popup when editing + LaunchedEffect(state.editingItem) { + if (state.editingItem != null) { + shouldShow = false + } + } + + // ═══════════════════════════════════════════════════════════ + // Close helpers + // ═══════════════════════════════════════════════════════════ + + fun animatedClose(afterClose: (() -> Unit)? = null) { + AttachAlertDebugLog.log("AttachAlert", "animatedClose() called, isClosing=$isClosing, captionInputActive=$captionInputActive, imeBottomInsetPx=$imeBottomInsetPx") + if (afterClose != null) closeAction = afterClose + if (!isClosing) isClosing = true + } + + fun requestClose(afterClose: (() -> Unit)? = null) { + if (state.selectedItemOrder.isNotEmpty()) { + pendingDismissAfterConfirm = afterClose + showDiscardSelectionDialog = true + return + } + animatedClose(afterClose) + } + + fun snapToNearestState(velocity: Float = 0f) { + animationScope.launch { + val currentHeight = sheetHeightPx.value + val velocityThreshold = 180f + val expandSnapThreshold = + collapsedStateHeightPx + (expandedHeightPx - collapsedStateHeightPx) * 0.35f + + when { + velocity > velocityThreshold && currentHeight < collapsedStateHeightPx -> requestClose() + currentHeight < minHeightPx + 30 -> requestClose() + velocity < -velocityThreshold -> { + isExpanded = true + sheetHeightPx.animateTo( + expandedHeightPx, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMediumLow + ) + ) + } + velocity > velocityThreshold -> { + if (isExpanded) { + isExpanded = false + sheetHeightPx.animateTo( + collapsedStateHeightPx, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMediumLow + ) + ) + } else requestClose() + } + currentHeight > expandSnapThreshold -> { + isExpanded = true + sheetHeightPx.animateTo( + expandedHeightPx, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMediumLow + ) + ) + } + else -> { + isExpanded = false + sheetHeightPx.animateTo( + collapsedStateHeightPx, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMediumLow + ) + ) + } + } + } + } + + // ═══════════════════════════════════════════════════════════ + // System bars + // ═══════════════════════════════════════════════════════════ + + val view = LocalView.current + val insetsController = remember(view) { + val window = (view.context as? Activity)?.window + window?.let { WindowCompat.getInsetsController(it, view) } + } + + val isPickerFullScreen by remember { + derivedStateOf { + val expandRange = (expandedHeightPx - collapsedStateHeightPx).coerceAtLeast(1f) + val progress = ((sheetHeightPx.value - collapsedStateHeightPx) / expandRange).coerceIn(0f, 1f) + progress > 0.9f + } + } + + DisposableEffect(shouldShow) { + if (shouldShow && !view.isInEditMode) { + val window = (view.context as? Activity)?.window + val origStatusBarColor = window?.statusBarColor ?: 0 + val origNavBarColor = window?.navigationBarColor ?: 0 + val origLightNav = insetsController?.isAppearanceLightNavigationBars ?: true + val origLightStatus = insetsController?.isAppearanceLightStatusBars ?: false + onDispose { + window?.statusBarColor = origStatusBarColor + window?.navigationBarColor = origNavBarColor + insetsController?.isAppearanceLightNavigationBars = origLightNav + insetsController?.isAppearanceLightStatusBars = origLightStatus + } + } else { + onDispose { } + } + } + + LaunchedEffect(shouldShow) { + if (!shouldShow) return@LaunchedEffect + val window = (view.context as? Activity)?.window ?: return@LaunchedEffect + snapshotFlow { Triple(scrimAlpha, isPickerFullScreen, isDarkTheme) } + .collect { (alpha, fullScreen, dark) -> + if (fullScreen) { + window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt() + insetsController?.isAppearanceLightStatusBars = !dark + } else { + window.statusBarColor = android.graphics.Color.argb( + (alpha * 255).toInt().coerceIn(0, 255), 0, 0, 0 + ) + insetsController?.isAppearanceLightStatusBars = false + } + window.navigationBarColor = android.graphics.Color.argb( + (alpha * 255).toInt().coerceIn(0, 255), 0, 0, 0 + ) + insetsController?.isAppearanceLightNavigationBars = alpha < 0.15f + } + } + + // ═══════════════════════════════════════════════════════════ + // Colors + // ═══════════════════════════════════════════════════════════ + + val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + val textColor = if (isDarkTheme) Color.White else Color.Black + val secondaryTextColor = Color(0xFF8E8E93) + + // ═══════════════════════════════════════════════════════════ + // POPUP RENDERING + // ═══════════════════════════════════════════════════════════ + + if (shouldShow) { + Popup( + alignment = Alignment.TopStart, + onDismissRequest = { + // Popup is always focusable, so it handles Back presses. + // Priority: hide keyboard → collapse → close + AttachAlertDebugLog.log("AttachAlert", "onDismissRequest: captionInputActive=$captionInputActive, isExpanded=$isExpanded, isClosing=$isClosing") + when { + captionInputActive -> hideKeyboard() + isExpanded -> { + animationScope.launch { + isExpanded = false + sheetHeightPx.animateTo( + collapsedStateHeightPx, + animationSpec = tween(250, easing = FastOutSlowInEasing) + ) + } + } + else -> requestClose() + } + }, + properties = PopupProperties( + focusable = true, // Always focusable (touch + back) + dismissOnBackPress = true, // Always handle back + dismissOnClickOutside = false + ) + ) { + // Capture popup root view for FLAG_ALT_FOCUSABLE_IM management + val localPopupView = LocalView.current + SideEffect { + popupRootView = localPopupView.rootView + } + + // Set FLAG_ALT_FOCUSABLE_IM on first show (non-IME mode) + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(16) // Wait for view to be added to WindowManager + AttachAlertDebugLog.log("AttachAlert", "Popup first-show: setting FLAG_ALT_FOCUSABLE_IM (non-IME mode)") + popupRootView?.let { updatePopupImeFocusable(it, false) } + } + + // Ensure FLAG_ALT_FOCUSABLE_IM is correct after any recomposition + // (Compose might reset window flags) + SideEffect { + popupRootView?.let { rootView -> + val params = rootView.layoutParams as? WindowManager.LayoutParams + ?: return@SideEffect + val shouldHaveAltFlag = !captionInputActive + val hasAltFlag = (params.flags and + WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) != 0 + if (shouldHaveAltFlag != hasAltFlag) { + updatePopupImeFocusable(rootView, captionInputActive) + } + } + } + + val keyboardInsetPx = + (imeBottomInsetPx.toFloat() - navigationBarInsetPx).coerceAtLeast(0f) + // Only shrink the sheet for keyboard when caption input is active. + // When FLAG_ALT_FOCUSABLE_IM is set, keyboard belongs to chat input behind picker. + val targetKeyboardInsetPx = + if (state.selectedItemOrder.isNotEmpty() && captionInputActive && !isClosing) keyboardInsetPx else 0f + // Smooth 250ms animation matching Telegram's AdjustPanLayoutHelper duration. + // This interpolates the keyboard offset so the sheet slides up/down smoothly + // instead of jumping instantly when the IME appears/disappears. + val animatedKeyboardInsetPx by animateFloatAsState( + targetValue = targetKeyboardInsetPx, + animationSpec = tween(250, easing = FastOutSlowInEasing), + label = "keyboard_inset_anim" + ) + val navBarDp = with(density) { navigationBarInsetPx.toDp() } + + // Log sheet geometry on every recomposition where values are interesting + if (imeBottomInsetPx > 0 || captionInputActive) { + AttachAlertDebugLog.log("AttachAlert", "GEOM: imePx=$imeBottomInsetPx, navBarPx=${navigationBarInsetPx.toInt()}, kbInsetPx=${keyboardInsetPx.toInt()}, targetKbPx=${targetKeyboardInsetPx.toInt()}, animatedKbPx=${animatedKeyboardInsetPx.toInt()}, sheetPx=${sheetHeightPx.value.toInt()}, captionActive=$captionInputActive, selected=${state.selectedItemOrder.size}, shouldShow=$shouldShow, isClosing=$isClosing") + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = scrimAlpha)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { requestClose() } + .padding(bottom = navBarDp), + contentAlignment = Alignment.BottomCenter + ) { + val visibleSheetHeightPx = + (sheetHeightPx.value - animatedKeyboardInsetPx).coerceAtLeast(minHeightPx) + val currentHeightDp = with(density) { visibleSheetHeightPx.toDp() } + val slideOffset = (visibleSheetHeightPx * animatedOffset).toInt() + val expandProgress = + ((sheetHeightPx.value - collapsedStateHeightPx) / + (expandedHeightPx - collapsedStateHeightPx).coerceAtLeast(1f)) + .coerceIn(0f, 1f) + val topCornerRadius by animateDpAsState( + targetValue = 16.dp * (1f - expandProgress), + animationSpec = tween(180, easing = FastOutSlowInEasing), + label = "sheet_top_corner_radius" + ) + val handleAlpha by animateFloatAsState( + targetValue = (1f - expandProgress * 1.35f).coerceIn(0f, 1f), + animationSpec = tween(160, easing = FastOutSlowInEasing), + label = "sheet_handle_alpha" + ) + val fullScreenHeaderProgress by animateFloatAsState( + targetValue = if (expandProgress > 0.94f) 1f else 0f, + animationSpec = tween(170, easing = FastOutSlowInEasing), + label = "gallery_fullscreen_header_progress" + ) + val collapsedHeaderAlpha = (1f - fullScreenHeaderProgress).coerceIn(0f, 1f) + val selectedCount = state.selectedCount + + var lastDragVelocity by remember { mutableFloatStateOf(0f) } + var dragSnapJob by remember { mutableStateOf(null) } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(currentHeightDp) + .offset { IntOffset(0, slideOffset) } + .graphicsLayer { + val scale = 0.95f + 0.05f * (1f - animatedOffset) + scaleX = scale + scaleY = scale + alpha = 0.9f + 0.1f * (1f - animatedOffset) + } + .clip(RoundedCornerShape(topStart = topCornerRadius, topEnd = topCornerRadius)) + .background(backgroundColor) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { /* Prevent click through */ } + .pointerInput(Unit) { + detectVerticalDragGestures( + onDragEnd = { + dragSnapJob?.cancel() + snapToNearestState(lastDragVelocity) + lastDragVelocity = 0f + }, + onVerticalDrag = { change, dragAmount -> + change.consume() + val adjustedDragAmount = + if (dragAmount < 0f) dragAmount * 1.25f else dragAmount * 0.9f + lastDragVelocity = adjustedDragAmount * 1.8f + val newHeight = (sheetHeightPx.value - adjustedDragAmount) + .coerceIn(minHeightPx, expandedHeightPx) + dragSnapJob?.cancel() + dragSnapJob = animationScope.launch { + sheetHeightPx.snapTo(newHeight) + } + } + ) + } + ) { + Column(modifier = Modifier.fillMaxSize()) { + // ── Header ── + + val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val currentAlbumTitle = + if (state.visibleAlbum?.isAllMedia != false) "Gallery" + else state.visibleAlbum?.name ?: "Gallery" + val selectionHeaderText = viewModel.selectionHeaderText() + + val selectionHeaderHeightPx = if (!isExpanded) { + (sheetHeightPx.value - collapsedStateHeightPx) + .coerceIn(0f, selectionHeaderExtraPx) + } else 0f + val selectionHeaderHeightDp = with(density) { selectionHeaderHeightPx.toDp() } + val selectionHeaderAlpha = + (selectionHeaderHeightPx / selectionHeaderExtraPx).coerceIn(0f, 1f) + + CollapsedHeader( + selectionHeaderText = selectionHeaderText, + selectionHeaderHeightDp = selectionHeaderHeightDp, + selectionHeaderAlpha = selectionHeaderAlpha, + handleAlpha = handleAlpha, + collapsedHeaderAlpha = collapsedHeaderAlpha, + textColor = textColor, + secondaryTextColor = secondaryTextColor + ) + + ExpandedHeader( + fullScreenHeaderProgress = fullScreenHeaderProgress, + statusBarTopPadding = statusBarTopPadding, + selectedCount = selectedCount, + currentAlbumTitle = currentAlbumTitle, + canSwitchAlbums = state.canSwitchAlbums, + showAlbumMenu = showAlbumMenu, + onShowAlbumMenu = { showAlbumMenu = true }, + onDismissAlbumMenu = { showAlbumMenu = false }, + albums = state.albums, + selectedAlbumId = state.selectedAlbumId, + onAlbumSelected = { viewModel.selectAlbum(it) }, + onClose = { requestClose() }, + isDarkTheme = isDarkTheme, + textColor = textColor, + secondaryTextColor = secondaryTextColor, + density = density + ) + + // ── Tab Content with AnimatedContent ── + + AnimatedContent( + targetState = state.currentTab, + transitionSpec = { + val dir = if (targetState.ordinal > initialState.ordinal) 1 else -1 + (slideInHorizontally { dir * it / 4 } + fadeIn(tween(200))) togetherWith + (slideOutHorizontally { -dir * it / 4 } + fadeOut(tween(150))) + }, + modifier = Modifier.weight(1f), + label = "tab_content" + ) { tab -> + when (tab) { + AttachAlertTab.PHOTO -> AttachAlertPhotoLayout( + state = state, + gridState = mediaGridState, + onCameraClick = { + requestClose { + hideKeyboard() + onOpenCamera() + } + }, + onItemClick = { item, position -> + if (!item.isVideo) { + thumbnailPosition = position + viewModel.setEditingItem(item) + } else { + viewModel.toggleSelection(item.id, maxSelection) + } + }, + onItemCheckClick = { item -> + viewModel.toggleSelection(item.id, maxSelection) + }, + onItemLongClick = { item -> + viewModel.toggleSelection(item.id, maxSelection) + }, + onRequestPermission = { requestPermissions() }, + isDarkTheme = isDarkTheme, + modifier = Modifier.fillMaxSize() + ) + AttachAlertTab.FILE -> AttachAlertFileLayout( + onOpenFilePicker = { + requestClose { onOpenFilePicker() } + }, + isDarkTheme = isDarkTheme, + modifier = Modifier.fillMaxSize() + ) + AttachAlertTab.AVATAR -> AttachAlertAvatarLayout( + onAvatarClick = { + requestClose { onAvatarClick() } + }, + isDarkTheme = isDarkTheme, + modifier = Modifier.fillMaxSize() + ) + } + } + + // ── Tab Bar (slides out when items selected) ── + + val bottomButtonsVisible = state.selectedItemOrder.isEmpty() && !isCaptionKeyboardVisible + val bottomButtonsHideProgress by animateFloatAsState( + targetValue = if (bottomButtonsVisible) 0f else 1f, + animationSpec = tween(180, easing = telegramButtonsEasing), + label = "bottom_buttons_hide_progress" + ) + val bottomButtonsFullHeight = 108.dp + val bottomButtonsHeight = bottomButtonsFullHeight * (1f - bottomButtonsHideProgress) + Box( + modifier = Modifier + .fillMaxWidth() + .height(bottomButtonsHeight) + .graphicsLayer { clip = true } + ) { + if (bottomButtonsHideProgress < 0.999f) { + val rowOffsetPx = + with(density) { bottomButtonsFullHeight.toPx() * bottomButtonsHideProgress } + AttachTabBar( + currentTab = state.currentTab, + onTabSelected = { viewModel.selectTab(it) }, + isDarkTheme = isDarkTheme, + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { + translationY = rowOffsetPx + alpha = 1f - bottomButtonsHideProgress + } + ) + } + } + + // ── Caption Bar ── + + CaptionBar( + captionBarProgress = captionBarProgress, + captionText = state.captionText, + onCaptionChange = { viewModel.updateCaption(it) }, + isCaptionKeyboardVisible = isCaptionKeyboardVisible, + onToggleKeyboard = { + if (isCaptionKeyboardVisible) hideKeyboard() + else focusCaptionInput() + }, + onFocusCaption = { focusCaptionInput() }, + onViewCreated = { captionEditTextView = it }, + isDarkTheme = isDarkTheme, + density = density + ) + } // end Column + + // ── Floating Send Button ── + + val sendButtonVisible = state.selectedItemOrder.isNotEmpty() + val sendButtonProgress by animateFloatAsState( + targetValue = if (sendButtonVisible) 1f else 0f, + animationSpec = tween(180, easing = FastOutSlowInEasing), + label = "send_button_progress" + ) + FloatingSendButton( + selectedCount = state.selectedCount, + sendButtonProgress = sendButtonProgress, + backgroundColor = backgroundColor, + onSend = { + val selected = viewModel.resolveSelectedMedia() + val caption = state.captionText.trim() + resetPickerCaptionInput() + onMediaSelected(selected, caption) + animatedClose() + }, + modifier = Modifier.align(Alignment.BottomEnd) + ) + + // ── Debug Log Button ── + var showDebugLogs by remember { mutableStateOf(false) } + + // Small debug FAB in top-left corner + Box( + modifier = Modifier + .align(Alignment.TopStart) + .padding(start = 8.dp, top = 8.dp) + .size(28.dp) + .clip(RoundedCornerShape(6.dp)) + .background(Color.Red.copy(alpha = 0.7f)) + .clickable { showDebugLogs = !showDebugLogs }, + contentAlignment = Alignment.Center + ) { + Text("L", color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Bold) + } + + // Debug log overlay + if (showDebugLogs) { + val logEntries = remember { mutableStateOf(AttachAlertDebugLog.entries) } + // Refresh logs periodically + LaunchedEffect(showDebugLogs) { + while (true) { + logEntries.value = AttachAlertDebugLog.entries + kotlinx.coroutines.delay(500) + } + } + Box( + modifier = Modifier + .align(Alignment.Center) + .fillMaxWidth(0.92f) + .fillMaxHeight(0.7f) + .clip(RoundedCornerShape(12.dp)) + .background(Color.Black.copy(alpha = 0.92f)) + .padding(8.dp) + ) { + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "AttachAlert Debug (${logEntries.value.size})", + color = Color.Green, + fontSize = 13.sp, + fontWeight = FontWeight.Bold + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "CLEAR", + color = Color.Yellow, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .clickable { + AttachAlertDebugLog.clear() + logEntries.value = emptyList() + } + .padding(horizontal = 6.dp, vertical = 2.dp) + ) + Text( + "✕", + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier + .clickable { showDebugLogs = false } + .padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + Spacer(Modifier.height(4.dp)) + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(6.dp)) + .background(Color(0xFF1A1A1A)) + ) { + val logListState = rememberLazyListState() + LaunchedEffect(logEntries.value.size) { + if (logEntries.value.isNotEmpty()) { + logListState.animateScrollToItem(logEntries.value.size - 1) + } + } + LazyColumn( + state = logListState, + modifier = Modifier.fillMaxSize().padding(6.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + items(logEntries.value) { entry -> + val entryColor = when { + entry.contains("[W/") -> Color(0xFFFF9800) + entry.contains("result=true") || entry.contains("OK") -> Color(0xFF4CAF50) + entry.contains("result=false") || entry.contains("failed") || entry.contains("NULL") -> Color(0xFFFF5252) + else -> Color(0xFFB0B0B0) + } + Text( + text = entry, + color = entryColor, + fontSize = 10.sp, + lineHeight = 13.sp, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + } + } + } // end Box sheet container + } + } + } + + // ═══════════════════════════════════════════════════════════ + // Discard dialog + // ═══════════════════════════════════════════════════════════ + + if (showDiscardSelectionDialog) { + AlertDialog( + onDismissRequest = { + showDiscardSelectionDialog = false + pendingDismissAfterConfirm = null + }, + containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, + title = { + Text( + text = "Discard selection?", + fontWeight = FontWeight.SemiBold, + color = textColor + ) + }, + text = { + Text( + text = "Selected photos will be removed.", + color = secondaryTextColor, + fontSize = 15.sp + ) + }, + dismissButton = { + TextButton(onClick = { + showDiscardSelectionDialog = false + pendingDismissAfterConfirm = null + }) { + Text("Cancel", color = PrimaryBlue) + } + }, + confirmButton = { + TextButton(onClick = { + val afterClose = pendingDismissAfterConfirm + showDiscardSelectionDialog = false + pendingDismissAfterConfirm = null + viewModel.clearSelection() + resetPickerCaptionInput() + animatedClose(afterClose) + }) { + Text("Discard", color = Color(0xFFFF3B30)) + } + } + ) + } + + // ═══════════════════════════════════════════════════════════ + // Image Editor overlays (gallery photo + camera photo) + // ═══════════════════════════════════════════════════════════ + + state.editingItem?.let { item -> + ImageEditorScreen( + imageUri = item.uri, + onDismiss = { + viewModel.setEditingItem(null) + thumbnailPosition = null + shouldShow = true + }, + onSave = { editedUri -> + viewModel.setEditingItem(null) + thumbnailPosition = null + if (onMediaSelectedWithCaption == null) { + previewPhotoUri = editedUri + } else { + val mediaItem = MediaItem( + id = System.currentTimeMillis(), + uri = editedUri, + mimeType = "image/png", + dateModified = System.currentTimeMillis() + ) + onMediaSelected(listOf(mediaItem), "") + onDismiss() + } + }, + onSaveWithCaption = if (onMediaSelectedWithCaption != null) { editedUri, caption -> + viewModel.setEditingItem(null) + thumbnailPosition = null + val mediaItem = MediaItem( + id = System.currentTimeMillis(), + uri = editedUri, + mimeType = "image/png", + dateModified = System.currentTimeMillis() + ) + onMediaSelectedWithCaption(mediaItem, caption) + onDismiss() + } else null, + isDarkTheme = isDarkTheme, + showCaptionInput = onMediaSelectedWithCaption != null, + recipientName = recipientName, + thumbnailPosition = thumbnailPosition + ) + } + + pendingPhotoUri?.let { uri -> + ImageEditorScreen( + imageUri = uri, + onDismiss = { pendingPhotoUri = null }, + onSave = { editedUri -> + pendingPhotoUri = null + if (onMediaSelectedWithCaption == null) { + previewPhotoUri = editedUri + } else { + val mediaItem = MediaItem( + id = System.currentTimeMillis(), + uri = editedUri, + mimeType = "image/png", + dateModified = System.currentTimeMillis() + ) + onMediaSelected(listOf(mediaItem), "") + onDismiss() + } + }, + onSaveWithCaption = if (onMediaSelectedWithCaption != null) { editedUri, caption -> + pendingPhotoUri = null + val mediaItem = MediaItem( + id = System.currentTimeMillis(), + uri = editedUri, + mimeType = "image/png", + dateModified = System.currentTimeMillis() + ) + onMediaSelectedWithCaption(mediaItem, caption) + onDismiss() + } else null, + isDarkTheme = isDarkTheme, + showCaptionInput = onMediaSelectedWithCaption != null, + recipientName = recipientName + ) + } + + previewPhotoUri?.let { uri -> + PhotoPreviewWithCaptionScreen( + imageUri = uri, + caption = photoCaption, + onCaptionChange = { photoCaption = it }, + onSend = { + val item = MediaItem( + id = System.currentTimeMillis(), + uri = uri, + mimeType = "image/png", + dateModified = System.currentTimeMillis() + ) + onMediaSelected(listOf(item), "") + previewPhotoUri = null + photoCaption = "" + onDismiss() + }, + onDismiss = { + previewPhotoUri = null + photoCaption = "" + }, + isDarkTheme = isDarkTheme + ) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/MediaRepository.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/MediaRepository.kt new file mode 100644 index 0000000..4d39247 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/MediaRepository.kt @@ -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() + + 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) +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index fc0e483..b7b60ba 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -1238,7 +1238,9 @@ fun ImageAttachment( Icon( painter = TelegramIcons.Done, 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) ) } @@ -1246,7 +1248,9 @@ fun ImageAttachment( Icon( painter = TelegramIcons.Done, 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) ) } @@ -1647,20 +1651,28 @@ fun FileAttachment( Icon( painter = TelegramIcons.Done, 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) ) } - MessageStatus.DELIVERED, MessageStatus.READ -> { + MessageStatus.DELIVERED -> { Icon( painter = TelegramIcons.Done, contentDescription = null, tint = - if (messageStatus == MessageStatus.READ) { - Color(0xFF4FC3F7) - } else { - Color.White.copy(alpha = 0.7f) - }, + if (isDarkTheme) Color.White + else Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(14.dp) + ) + } + MessageStatus.READ -> { + Icon( + painter = TelegramIcons.Done, + contentDescription = null, + tint = + if (isDarkTheme) Color.White else Color(0xFF4FC3F7), modifier = Modifier.size(14.dp) ) } @@ -2035,7 +2047,9 @@ fun AvatarAttachment( Icon( painter = TelegramIcons.Done, 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) ) } @@ -2043,7 +2057,9 @@ fun AvatarAttachment( Icon( painter = TelegramIcons.Done, 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) ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index f5a3ff6..7b56d46 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -324,6 +324,10 @@ fun MessageBubble( if (message.isOutgoing) Color.White.copy(alpha = 0.7f) else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) } + val statusColor = + remember(message.isOutgoing, isDarkTheme, timeColor) { + if (message.isOutgoing) Color.White else timeColor + } val isSafeSystemMessage = isSystemSafeChat && @@ -817,7 +821,11 @@ fun MessageBubble( status = displayStatus, timeColor = - timeColor, + statusColor, + isDarkTheme = + isDarkTheme, + isOutgoing = + message.isOutgoing, timestamp = message.timestamp .time, @@ -901,7 +909,11 @@ fun MessageBubble( status = displayStatus, timeColor = - timeColor, + statusColor, + isDarkTheme = + isDarkTheme, + isOutgoing = + message.isOutgoing, timestamp = message.timestamp .time, @@ -978,7 +990,11 @@ fun MessageBubble( status = displayStatus, timeColor = - timeColor, + statusColor, + isDarkTheme = + isDarkTheme, + isOutgoing = + message.isOutgoing, timestamp = message.timestamp .time, @@ -1078,6 +1094,8 @@ private fun buildSafeSystemAnnotatedText(text: String) = buildAnnotatedString { fun AnimatedMessageStatus( status: MessageStatus, timeColor: Color, + isDarkTheme: Boolean = false, + isOutgoing: Boolean = false, timestamp: Long = 0L, onRetry: () -> Unit = {}, onDelete: () -> Unit = {} @@ -1090,7 +1108,7 @@ fun AnimatedMessageStatus( val targetColor = when (effectiveStatus) { - MessageStatus.READ -> Color(0xFF4FC3F7) + MessageStatus.READ -> if (isDarkTheme || isOutgoing) Color.White else Color(0xFF4FC3F7) MessageStatus.ERROR -> Color(0xFFE53935) else -> timeColor } @@ -2126,6 +2144,7 @@ fun OtherProfileMenu( onDismiss: () -> Unit, isDarkTheme: Boolean, isBlocked: Boolean, + isSystemAccount: Boolean = false, onBlockClick: () -> Unit, onClearChatClick: () -> Unit ) { @@ -2153,13 +2172,15 @@ fun OtherProfileMenu( dismissOnClickOutside = true ) ) { - ProfilePhotoMenuItem( - icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block, - text = if (isBlocked) "Unblock User" else "Block User", - onClick = onBlockClick, - tintColor = iconColor, - textColor = textColor - ) + if (!isSystemAccount) { + ProfilePhotoMenuItem( + icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block, + text = if (isBlocked) "Unblock User" else "Block User", + onClick = onBlockClick, + tintColor = iconColor, + textColor = textColor + ) + } ProfilePhotoMenuItem( icon = TelegramIcons.Delete, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt index 3cdb4b3..f9827d6 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageEditorScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.zIndex import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap 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.sp import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.exifinterface.media.ExifInterface import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator @@ -144,6 +147,28 @@ fun ImageEditorScreen( thumbnailPosition: ThumbnailPosition? = null, // Позиция для Telegram-style анимации 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 scope = rememberCoroutineScope() val view = LocalView.current @@ -186,7 +211,7 @@ fun ImageEditorScreen( SystemBarsStyleUtils.capture(window, view) } - LaunchedEffect(window, view) { + SideEffect { SystemBarsStyleUtils.applyFullscreenDark(window, view) } @@ -201,7 +226,7 @@ fun ImageEditorScreen( ) } } - + // Функция для плавного закрытия fun animatedDismiss() { if (isClosing) return @@ -315,6 +340,9 @@ fun ImageEditorScreen( var photoEditor by remember { mutableStateOf(null) } var photoEditorView by remember { mutableStateOf(null) } + // Track whether user made drawing edits (to skip PhotoEditor save when only cropping) + var hasDrawingEdits by remember { mutableStateOf(false) } + // UCrop launcher val cropLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult() @@ -386,18 +414,17 @@ fun ImageEditorScreen( animatedBackgroundAlpha = progress } - // Telegram behavior: photo stays fullscreen, only input moves with keyboard Box( modifier = Modifier .fillMaxSize() - // 🔥 Блокируем свайпы от SwipeBackContainer - на ImageEditor свайпы не должны работать + .background(Color.Black) + .zIndex(100f) .pointerInput(Unit) { awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false) - down.consume() // Поглощаем все touch события + down.consume() } } - .background(Color.Black) ) { Box( modifier = @@ -438,9 +465,7 @@ fun ImageEditorScreen( modifier = Modifier.fillMaxSize() ) - // ═══════════════════════════════════════════════════════════ - // 🎛️ TOP BAR - Solid black (Telegram style) - // ═══════════════════════════════════════════════════════════ + // TOP BAR Box( modifier = Modifier .fillMaxWidth() @@ -639,7 +664,8 @@ fun ImageEditorScreen( context = context, photoEditor = photoEditor, photoEditorView = photoEditorView, - imageUri = uriToSend + imageUri = uriToSend, + hasDrawingEdits = hasDrawingEdits ) isSaving = false @@ -735,6 +761,7 @@ fun ImageEditorScreen( } } else { currentTool = EditorTool.DRAW + hasDrawingEdits = true isEraserActive = false photoEditor?.setBrushDrawingMode(true) photoEditor?.brushColor = selectedColor.toArgb() @@ -769,7 +796,8 @@ fun ImageEditorScreen( context = context, photoEditor = photoEditor, photoEditorView = photoEditorView, - imageUri = currentImageUri + imageUri = currentImageUri, + hasDrawingEdits = hasDrawingEdits ) isSaving = false onSave(savedUri ?: currentImageUri) @@ -783,6 +811,7 @@ fun ImageEditorScreen( } } } + } // Dialog } /** @@ -1220,12 +1249,12 @@ private suspend fun saveEditedImageOld( } try { - val tempFile = File(context.cacheDir, "edited_${System.currentTimeMillis()}.png") + val tempFile = File(context.cacheDir, "edited_${System.currentTimeMillis()}.jpg") val saveSettings = SaveSettings.Builder() .setClearViewsEnabled(false) - .setTransparencyEnabled(true) - .setCompressFormat(Bitmap.CompressFormat.PNG) - .setCompressQuality(100) + .setTransparencyEnabled(false) + .setCompressFormat(Bitmap.CompressFormat.JPEG) + .setCompressQuality(95) .build() val savedPath = suspendCancellableCoroutine { continuation -> @@ -1265,8 +1294,13 @@ private suspend fun saveEditedImageSync( context: Context, photoEditor: PhotoEditor?, photoEditorView: PhotoEditorView?, - imageUri: Uri + imageUri: Uri, + hasDrawingEdits: Boolean = false ): Uri? { + // No drawing edits — return the source URI directly (clean crop from UCrop, no letterbox) + if (!hasDrawingEdits) { + return imageUri + } return saveEditedImageSyncOld( context = context, photoEditor = photoEditor, @@ -1291,14 +1325,14 @@ private suspend fun saveEditedImageSyncOld( return try { val tempFile = File( context.cacheDir, - "edited_${System.currentTimeMillis()}_${(0..9999).random()}.png" + "edited_${System.currentTimeMillis()}_${(0..9999).random()}.jpg" ) val saveSettings = SaveSettings.Builder() .setClearViewsEnabled(false) - .setTransparencyEnabled(true) - .setCompressFormat(Bitmap.CompressFormat.PNG) - .setCompressQuality(100) + .setTransparencyEnabled(false) + .setCompressFormat(Bitmap.CompressFormat.JPEG) + .setCompressQuality(95) .build() val savedPath = suspendCancellableCoroutine { continuation -> @@ -1381,8 +1415,10 @@ private suspend fun removeLetterboxFromEditedImage( return@runCatching editedUri } - // Safety guard against invalid/asymmetric crop. - if (cropWidth < editedWidth / 3 || cropHeight < editedHeight / 3) { + // Safety guard: only skip if crop is unreasonably small (< 5% of area) + if (cropWidth < 10 || cropHeight < 10 || + (cropWidth.toLong() * cropHeight.toLong()) < (editedWidth.toLong() * editedHeight.toLong()) / 20 + ) { editedBitmap.recycle() return@runCatching editedUri } @@ -1400,10 +1436,10 @@ private suspend fun removeLetterboxFromEditedImage( val normalizedFile = File( context.cacheDir, - "edited_normalized_${System.currentTimeMillis()}_${(0..9999).random()}.png" + "edited_normalized_${System.currentTimeMillis()}_${(0..9999).random()}.jpg" ) normalizedFile.outputStream().use { out -> - cropped.compress(Bitmap.CompressFormat.PNG, 100, out) + cropped.compress(Bitmap.CompressFormat.JPEG, 95, out) out.flush() } cropped.recycle() @@ -1448,18 +1484,18 @@ private fun launchCrop( launcher: androidx.activity.result.ActivityResultLauncher ) { 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 options = UCrop.Options().apply { - setCompressionFormat(Bitmap.CompressFormat.PNG) - setCompressionQuality(100) + setCompressionFormat(Bitmap.CompressFormat.JPEG) + setCompressionQuality(95) // Dark theme setToolbarColor(android.graphics.Color.BLACK) setStatusBarColor(android.graphics.Color.BLACK) setActiveControlsWidgetColor(android.graphics.Color.parseColor("#007AFF")) setToolbarWidgetColor(android.graphics.Color.WHITE) - setRootViewBackgroundColor(android.graphics.Color.BLACK) + setRootViewBackgroundColor(android.graphics.Color.WHITE) setFreeStyleCropEnabled(true) setShowCropGrid(true) setShowCropFrame(true) @@ -1488,6 +1524,27 @@ fun MultiImageEditorScreen( isDarkTheme: Boolean = true, 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 scope = rememberCoroutineScope() val view = LocalView.current @@ -1540,7 +1597,7 @@ fun MultiImageEditorScreen( SystemBarsStyleUtils.capture(window, view) } - LaunchedEffect(window, view) { + SideEffect { SystemBarsStyleUtils.applyFullscreenDark(window, view) } @@ -1572,6 +1629,7 @@ fun MultiImageEditorScreen( val photoEditors = remember { mutableStateMapOf() } val photoEditorViews = remember { mutableStateMapOf() } + val drawingEditPages = remember { mutableSetOf() } val cropLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult() @@ -1605,11 +1663,11 @@ fun MultiImageEditorScreen( BackHandler { animatedDismiss() } - // ⚡ Простой fade - только opacity Box( modifier = Modifier .fillMaxSize() .background(Color.Black) + .zIndex(100f) ) { Box( modifier = @@ -1834,6 +1892,7 @@ fun MultiImageEditorScreen( showColorPicker = !showColorPicker } else { currentTool = EditorTool.DRAW + drawingEditPages.add(pagerState.currentPage) currentEditor?.setBrushDrawingMode(true) currentEditor?.brushColor = selectedColor.toArgb() currentEditor?.brushSize = brushSize @@ -1875,7 +1934,7 @@ fun MultiImageEditorScreen( val originalImage = imagesToSend[i] 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) { savedImages.add(originalImage.copy(uri = savedUri)) } else { @@ -1906,6 +1965,7 @@ fun MultiImageEditorScreen( } } } + } // Dialog } private fun loadBitmapRespectExif(context: Context, uri: Uri): Bitmap? { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt index 0787da5..f2f285f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt @@ -56,6 +56,7 @@ import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing import androidx.compose.ui.graphics.graphicsLayer +import com.rosetta.messenger.data.PreferencesManager /** * 📷 In-App Camera Screen - как в Telegram @@ -73,11 +74,18 @@ fun InAppCameraScreen( val view = LocalView.current val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current + val preferencesManager = remember(context.applicationContext) { + PreferencesManager(context.applicationContext) + } // Camera state var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) } var flashMode by remember { mutableStateOf(ImageCapture.FLASH_MODE_AUTO) } var isCapturing by remember { mutableStateOf(false) } + + LaunchedEffect(preferencesManager) { + flashMode = preferencesManager.getCameraFlashMode() + } // Camera references var imageCapture by remember { mutableStateOf(null) } @@ -231,27 +239,27 @@ fun InAppCameraScreen( // PreviewView reference var previewView by remember { mutableStateOf(null) } - // Bind camera when previewView, lensFacing or flashMode changes - LaunchedEffect(previewView, lensFacing, flashMode) { + // Bind camera when previewView or lensFacing changes (NOT flashMode — that causes flicker) + LaunchedEffect(previewView, lensFacing) { val pv = previewView ?: return@LaunchedEffect - + val provider = context.getCameraProvider() cameraProvider = provider - + val preview = Preview.Builder().build().also { it.setSurfaceProvider(pv.surfaceProvider) } - + val capture = ImageCapture.Builder() .setFlashMode(flashMode) .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) .build() imageCapture = capture - + val cameraSelector = CameraSelector.Builder() .requireLensFacing(lensFacing) .build() - + try { provider.unbindAll() provider.bindToLifecycle( @@ -263,6 +271,11 @@ fun InAppCameraScreen( } catch (e: Exception) { } } + + // Apply flash mode directly without rebinding camera (prevents flicker) + LaunchedEffect(flashMode) { + imageCapture?.flashMode = flashMode + } // Unbind on dispose DisposableEffect(Unit) { @@ -318,11 +331,15 @@ fun InAppCameraScreen( // Flash button IconButton( onClick = { - flashMode = when (flashMode) { + val nextFlashMode = when (flashMode) { ImageCapture.FLASH_MODE_OFF -> ImageCapture.FLASH_MODE_AUTO ImageCapture.FLASH_MODE_AUTO -> ImageCapture.FLASH_MODE_ON else -> ImageCapture.FLASH_MODE_OFF } + flashMode = nextFlashMode + scope.launch { + preferencesManager.setCameraFlashMode(nextFlashMode) + } }, modifier = Modifier .size(44.dp) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt index aee6583..a050c4b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt @@ -32,6 +32,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput @@ -50,8 +51,6 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import androidx.activity.compose.BackHandler @@ -73,12 +72,15 @@ import com.rosetta.messenger.ui.components.OptimizedEmojiPicker import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.math.roundToInt private const val TAG = "MediaPickerBottomSheet" +private const val ALL_MEDIA_ALBUM_ID = 0L /** * Media item from gallery @@ -88,11 +90,20 @@ data class MediaItem( val uri: Uri, val mimeType: String, val duration: Long = 0, // For videos, in milliseconds - val dateModified: Long = 0 + 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, + val isAllMedia: Boolean = false +) + /** * 📸 Telegram-style Media Picker - INLINE OVERLAY * @@ -119,6 +130,7 @@ fun MediaPickerBottomSheet( val context = LocalContext.current val scope = rememberCoroutineScope() val density = LocalDensity.current + val imeInsets = WindowInsets.ime val focusManager = LocalFocusManager.current val keyboardView = LocalView.current @@ -143,11 +155,13 @@ fun MediaPickerBottomSheet( // Media items from gallery var mediaItems by remember { mutableStateOf>(emptyList()) } + var mediaAlbums by remember { mutableStateOf>(emptyList()) } + var selectedAlbumId by remember { mutableLongStateOf(ALL_MEDIA_ALBUM_ID) } var isLoading by remember { mutableStateOf(true) } var hasPermission by remember { mutableStateOf(false) } - // Selected items - var selectedItems by remember { mutableStateOf>(emptySet()) } + // Selected items in selection order (Telegram-style) + var selectedItemOrder by remember { mutableStateOf>(emptyList()) } // Editor state - when user taps on a photo, open editor var editingItem by remember { mutableStateOf(null) } @@ -167,6 +181,45 @@ fun MediaPickerBottomSheet( // Caption для группы фото (внизу picker'а) var pickerCaption by remember { mutableStateOf("") } var captionEditTextView by remember { mutableStateOf(null) } + + fun resetPickerCaptionInput() { + pickerCaption = "" + captionEditTextView?.clearFocus() + } + + fun focusCaptionInput() { + captionEditTextView?.let { view -> + view.requestFocus() + view.post { + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) + } + } + } + + fun resolveSelectedMedia(): List { + if (selectedItemOrder.isEmpty()) return emptyList() + val byId = mediaItems.associateBy { it.id } + return selectedItemOrder.mapNotNull { byId[it] } + } + + val selectedAlbum = + remember(mediaAlbums, selectedAlbumId) { + mediaAlbums.firstOrNull { it.id == selectedAlbumId } ?: mediaAlbums.firstOrNull() + } + val visibleMediaItems = selectedAlbum?.items ?: mediaItems + val canSwitchAlbums = mediaAlbums.size > 1 + + fun toggleSelection(itemId: Long) { + selectedItemOrder = + if (selectedItemOrder.contains(itemId)) { + selectedItemOrder.filterNot { it == itemId } + } else if (selectedItemOrder.size < maxSelection) { + selectedItemOrder + itemId + } else { + selectedItemOrder + } + } // Permission launcher val permissionLauncher = rememberLauncherForActivityResult( @@ -175,7 +228,10 @@ fun MediaPickerBottomSheet( hasPermission = permissions.values.all { it } if (hasPermission) { scope.launch { - mediaItems = loadMediaItems(context) + val loaded = loadMediaPickerData(context) + mediaItems = loaded.items + mediaAlbums = loaded.albums + selectedAlbumId = loaded.albums.firstOrNull()?.id ?: ALL_MEDIA_ALBUM_ID isLoading = false } } @@ -185,7 +241,9 @@ fun MediaPickerBottomSheet( LaunchedEffect(isVisible) { if (isVisible) { // Reset selection when opening - selectedItems = emptySet() + selectedItemOrder = emptyList() + resetPickerCaptionInput() + selectedAlbumId = ALL_MEDIA_ALBUM_ID val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { arrayOf( @@ -206,7 +264,10 @@ fun MediaPickerBottomSheet( if (hasPermission) { isLoading = true - mediaItems = loadMediaItems(context) + val loaded = loadMediaPickerData(context) + mediaItems = loaded.items + mediaAlbums = loaded.albums + selectedAlbumId = loaded.albums.firstOrNull()?.id ?: ALL_MEDIA_ALBUM_ID isLoading = false } else { permissionLauncher.launch(permissions) @@ -226,11 +287,23 @@ fun MediaPickerBottomSheet( val configuration = LocalConfiguration.current val screenHeight = configuration.screenHeightDp.dp val screenHeightPx = with(density) { screenHeight.toPx() } - + val navigationBarInsetPx = WindowInsets.navigationBars.getBottom(density).toFloat() + val statusBarInsetPx = WindowInsets.statusBars.getTop(density).toFloat() + // 🔄 Высоты в пикселях для точного контроля val collapsedHeightPx = screenHeightPx * 0.50f // Свёрнутое - 50% экрана (легче тянуть дальше вверх) - val expandedHeightPx = screenHeightPx * 0.88f // Развёрнутое - 88% экрана + val expandedHeightPx = + (screenHeightPx + statusBarInsetPx).coerceAtLeast(screenHeightPx * 0.95f) val minHeightPx = screenHeightPx * 0.2f // Минимум при свайпе вниз + val captionLiftEasing = CubicBezierEasing(0f, 0f, 0.2f, 1f) + val telegramButtonsEasing = CubicBezierEasing(0f, 0f, 0.58f, 1f) + val collapsedStateHeightPx = collapsedHeightPx.coerceAtMost(expandedHeightPx) + val selectionHeaderExtraPx = with(density) { 26.dp.toPx() } // Telegram: 26dp header offset + val captionBarProgress by animateFloatAsState( + targetValue = if (selectedItemOrder.isNotEmpty()) 1f else 0f, + animationSpec = tween(180, easing = captionLiftEasing), + label = "caption_bar_progress" + ) // 🎬 Animatable для плавной высоты - КЛЮЧЕВОЙ ЭЛЕМЕНТ val sheetHeightPx = remember { Animatable(collapsedHeightPx) } @@ -242,6 +315,12 @@ fun MediaPickerBottomSheet( var isClosing by remember { mutableStateOf(false) } var shouldShow by remember { mutableStateOf(false) } var closeAction by remember { mutableStateOf<(() -> Unit)?>(null) } + var showDiscardSelectionDialog by remember { mutableStateOf(false) } + var pendingDismissAfterConfirm by remember { mutableStateOf<(() -> Unit)?>(null) } + var hadSelection by remember { mutableStateOf(false) } + var isCaptionKeyboardVisible by remember { mutableStateOf(false) } + var showAlbumMenu by remember { mutableStateOf(false) } + var imeBottomInsetPx by remember { mutableIntStateOf(0) } // Scope для анимаций val animationScope = rememberCoroutineScope() @@ -277,7 +356,8 @@ fun MediaPickerBottomSheet( val scrimAlpha by animateFloatAsState( targetValue = if (shouldShow && !isClosing) { // Базовое затемнение + немного больше когда развёрнуто - val expandProgress = (sheetHeightPx.value - collapsedHeightPx) / (expandedHeightPx - collapsedHeightPx) + val expandRange = (expandedHeightPx - collapsedStateHeightPx).coerceAtLeast(1f) + val expandProgress = (sheetHeightPx.value - collapsedStateHeightPx) / expandRange 0.25f + 0.15f * expandProgress.coerceIn(0f, 1f) } else { 0f @@ -291,14 +371,61 @@ fun MediaPickerBottomSheet( // Показываем галерею (но НЕ когда редактируем фото) val showSheet = isVisible && pendingPhotoUri == null && previewPhotoUri == null - val showGalleryContent = showSheet && editingItem == null && !isClosing + val mediaGridState = rememberLazyGridState() // Запускаем анимацию когда showSheet меняется LaunchedEffect(showSheet) { if (showSheet && editingItem == null) { shouldShow = true isClosing = false - sheetHeightPx.snapTo(collapsedHeightPx) + showAlbumMenu = false + sheetHeightPx.snapTo(collapsedStateHeightPx) + } + } + + // Плавно синхронизируем высоту sheet с UI выбора (caption + send) в collapsed-состоянии. + // When items are selected, the sheet grows UPWARD by 26dp to reveal the "N photos selected" header. + LaunchedEffect(showSheet, selectedItemOrder.isNotEmpty(), isExpanded, isClosing, editingItem) { + if (showSheet && editingItem == null && !isClosing && !isExpanded) { + val targetHeight = if (selectedItemOrder.isNotEmpty()) { + collapsedStateHeightPx + selectionHeaderExtraPx + } else { + collapsedStateHeightPx + } + if (kotlin.math.abs(sheetHeightPx.value - targetHeight) > 1f) { + sheetHeightPx.stop() + sheetHeightPx.animateTo( + targetHeight, + animationSpec = tween(180, easing = captionLiftEasing) + ) + } + } + } + + LaunchedEffect(showSheet) { + if (showSheet) { + snapshotFlow { imeInsets.getBottom(density) } + .collect { imeBottom -> + imeBottomInsetPx = imeBottom + isCaptionKeyboardVisible = imeBottom > 0 + } + } else { + imeBottomInsetPx = 0 + isCaptionKeyboardVisible = false + } + } + + LaunchedEffect(selectedAlbumId) { + mediaGridState.scrollToItem(0) + } + + // Telegram behavior: when selection is fully cleared, caption input closes with keyboard. + LaunchedEffect(selectedItemOrder.size) { + if (selectedItemOrder.isNotEmpty()) { + hadSelection = true + } else if (hadSelection) { + hadSelection = false + hideKeyboard() } } @@ -320,6 +447,15 @@ fun MediaPickerBottomSheet( isClosing = true } } + + fun requestClose(afterClose: (() -> Unit)? = null) { + if (selectedItemOrder.isNotEmpty()) { + pendingDismissAfterConfirm = afterClose + showDiscardSelectionDialog = true + return + } + animatedClose(afterClose) + } // Функция snap к ближайшему состоянию с плавной анимацией // Используем velocity для определения направления @@ -330,16 +466,17 @@ fun MediaPickerBottomSheet( // Пороги основаны на velocity (скорости свайпа) - не на позиции! // velocity < 0 = свайп вверх, velocity > 0 = свайп вниз val velocityThreshold = 180f // Более низкий порог: легче раскрыть sheet свайпом вверх - val expandSnapThreshold = collapsedHeightPx + (expandedHeightPx - collapsedHeightPx) * 0.35f + val expandSnapThreshold = + collapsedStateHeightPx + (expandedHeightPx - collapsedStateHeightPx) * 0.35f when { // Быстрый свайп вниз при минимальной высоте - закрываем - velocity > velocityThreshold && currentHeight < collapsedHeightPx -> { - animatedClose() + velocity > velocityThreshold && currentHeight < collapsedStateHeightPx -> { + requestClose() } // Слишком низко - закрываем currentHeight < minHeightPx + 30 -> { - animatedClose() + requestClose() } // Быстрый свайп вверх - разворачиваем velocity < -velocityThreshold -> { @@ -357,14 +494,14 @@ fun MediaPickerBottomSheet( if (isExpanded) { isExpanded = false sheetHeightPx.animateTo( - collapsedHeightPx, + collapsedStateHeightPx, animationSpec = spring( dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessMediumLow ) ) } else { - animatedClose() + requestClose() } } // Без velocity - snap к ближайшему @@ -381,7 +518,7 @@ fun MediaPickerBottomSheet( else -> { isExpanded = false sheetHeightPx.animateTo( - collapsedHeightPx, + collapsedStateHeightPx, animationSpec = spring( dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessMediumLow @@ -392,43 +529,68 @@ fun MediaPickerBottomSheet( } } - // 🎨 Затемнение статус бара и навигейшн бара когда галерея открыта + // 🎨 Статус бар и навигейшн бар — единый источник правды val view = LocalView.current - // 🔥 Контроллер для управления цветом иконок navigation bar val insetsController = remember(view) { val window = (view.context as? android.app.Activity)?.window window?.let { WindowCompat.getInsetsController(it, view) } } - DisposableEffect(shouldShow, scrimAlpha) { + // Fullscreen state derived from sheet height (available at top level) + val isPickerFullScreen by remember { + derivedStateOf { + val expandRange = (expandedHeightPx - collapsedStateHeightPx).coerceAtLeast(1f) + val progress = ((sheetHeightPx.value - collapsedStateHeightPx) / expandRange).coerceIn(0f, 1f) + progress > 0.9f + } + } + + // Save originals ONCE when picker opens, restore on close + DisposableEffect(shouldShow) { if (shouldShow && !view.isInEditMode) { val window = (view.context as? android.app.Activity)?.window - val originalStatusBarColor = window?.statusBarColor ?: 0 - val originalNavigationBarColor = window?.navigationBarColor ?: 0 - val originalLightNavigationBars = insetsController?.isAppearanceLightNavigationBars ?: true - - // Затемняем статус бар и навигейшн бар - val scrimColor = android.graphics.Color.argb( - (scrimAlpha * 255).toInt().coerceIn(0, 255), - 0, 0, 0 - ) - window?.statusBarColor = scrimColor - window?.navigationBarColor = scrimColor - - // 🔥 Иконки навигейшн бара светлые (белые) когда scrim виден - insetsController?.isAppearanceLightNavigationBars = scrimAlpha < 0.15f && originalLightNavigationBars + val origStatusBarColor = window?.statusBarColor ?: 0 + val origNavBarColor = window?.navigationBarColor ?: 0 + val origLightNav = insetsController?.isAppearanceLightNavigationBars ?: true + val origLightStatus = insetsController?.isAppearanceLightStatusBars ?: false onDispose { - // Восстанавливаем оригинальные цвета - window?.statusBarColor = originalStatusBarColor - window?.navigationBarColor = originalNavigationBarColor - insetsController?.isAppearanceLightNavigationBars = originalLightNavigationBars + window?.statusBarColor = origStatusBarColor + window?.navigationBarColor = origNavBarColor + insetsController?.isAppearanceLightNavigationBars = origLightNav + insetsController?.isAppearanceLightStatusBars = origLightStatus } } else { onDispose { } } } + + // Reactive updates — single snapshotFlow drives ALL system bar colors + LaunchedEffect(shouldShow) { + if (!shouldShow) return@LaunchedEffect + val window = (view.context as? android.app.Activity)?.window ?: return@LaunchedEffect + + snapshotFlow { Triple(scrimAlpha, isPickerFullScreen, isDarkTheme) } + .collect { (alpha, fullScreen, dark) -> + if (fullScreen) { + // Full screen: status bar = picker background, seamless + window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt() + insetsController?.isAppearanceLightStatusBars = !dark + } else { + // Collapsed: semi-transparent scrim + window.statusBarColor = android.graphics.Color.argb( + (alpha * 255).toInt().coerceIn(0, 255), 0, 0, 0 + ) + insetsController?.isAppearanceLightStatusBars = false + } + // Navigation bar always follows scrim + window.navigationBarColor = android.graphics.Color.argb( + (alpha * 255).toInt().coerceIn(0, 255), 0, 0, 0 + ) + insetsController?.isAppearanceLightNavigationBars = alpha < 0.15f + } + } // Используем Popup для показа поверх клавиатуры if (shouldShow) { @@ -438,54 +600,74 @@ fun MediaPickerBottomSheet( animationScope.launch { isExpanded = false sheetHeightPx.animateTo( - collapsedHeightPx, + collapsedStateHeightPx, animationSpec = tween(250, easing = FastOutSlowInEasing) ) } } else { - animatedClose() + requestClose() } } Popup( alignment = Alignment.TopStart, // Начинаем с верха чтобы покрыть весь экран - onDismissRequest = { animatedClose() }, + onDismissRequest = { requestClose() }, properties = PopupProperties( focusable = true, dismissOnBackPress = true, dismissOnClickOutside = false ) ) { - // 🔥 Получаем высоту navigation bar и IME (клавиатуры) - val navigationBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() - val imeHeight = WindowInsets.ime.asPaddingValues().calculateBottomPadding() - val navigationBarHeightPx = with(density) { navigationBarHeight.toPx().toInt() } - val imeHeightPx = with(density) { imeHeight.toPx().toInt() } - - // 🔥 Если клавиатура открыта - не добавляем navigationBar offset (клавиатура его перекрывает) - val bottomOffset = if (imeHeightPx > 0) 0 else navigationBarHeightPx + // With enableEdgeToEdge() + focusable Popup, the system resizes the Popup + // window when the keyboard opens (adjustResize). So we only need navBar padding. + // However, the sheet has a FIXED pixel height that doesn't adapt to the smaller + // viewport — we must subtract keyboard from the visible sheet height so the + // caption bar stays visible. The grid (weight=1f) absorbs the shrinkage. + val keyboardInsetPx = + (imeBottomInsetPx.toFloat() - navigationBarInsetPx).coerceAtLeast(0f) + val appliedKeyboardInsetPx = + if (selectedItemOrder.isNotEmpty()) keyboardInsetPx else 0f + val navBarDp = with(density) { navigationBarInsetPx.toDp() } // Полноэкранный контейнер с мягким затемнением + // background BEFORE padding — scrim covers area behind keyboard too Box( modifier = Modifier .fillMaxSize() - .imePadding() .background(Color.Black.copy(alpha = scrimAlpha)) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null - ) { animatedClose() }, + ) { requestClose() } + .padding(bottom = navBarDp), contentAlignment = Alignment.BottomCenter ) { - // Sheet content - val currentHeightDp = with(density) { sheetHeightPx.value.toDp() } - // 🔥 Добавляем смещение вниз только если клавиатура закрыта - val slideOffset = (sheetHeightPx.value * animatedOffset).toInt() + bottomOffset + // Subtract keyboard from sheet height so it fits in the resized viewport. + // The grid (weight=1f) shrinks; caption bar stays at the bottom edge. + val visibleSheetHeightPx = + (sheetHeightPx.value - appliedKeyboardInsetPx).coerceAtLeast(minHeightPx) + val currentHeightDp = with(density) { visibleSheetHeightPx.toDp() } + val slideOffset = (visibleSheetHeightPx * animatedOffset).toInt() + val expandProgress = + ((sheetHeightPx.value - collapsedStateHeightPx) / + (expandedHeightPx - collapsedStateHeightPx).coerceAtLeast(1f)) + .coerceIn(0f, 1f) + val topCornerRadius by animateDpAsState( + targetValue = 16.dp * (1f - expandProgress), + animationSpec = tween(180, easing = FastOutSlowInEasing), + label = "sheet_top_corner_radius" + ) + val handleAlpha by animateFloatAsState( + targetValue = (1f - expandProgress * 1.35f).coerceIn(0f, 1f), + animationSpec = tween(160, easing = FastOutSlowInEasing), + label = "sheet_handle_alpha" + ) // Отслеживаем velocity для плавного snap var lastDragVelocity by remember { mutableFloatStateOf(0f) } + var dragSnapJob by remember { mutableStateOf(null) } - Column( + Box( modifier = Modifier .fillMaxWidth() .height(currentHeightDp) @@ -497,7 +679,7 @@ fun MediaPickerBottomSheet( scaleY = scale alpha = 0.9f + 0.1f * (1f - animatedOffset) } - .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) + .clip(RoundedCornerShape(topStart = topCornerRadius, topEnd = topCornerRadius)) .background(backgroundColor) .clickable( interactionSource = remember { MutableInteractionSource() }, @@ -507,6 +689,7 @@ fun MediaPickerBottomSheet( detectVerticalDragGestures( onDragEnd = { // Snap с учётом velocity + dragSnapJob?.cancel() snapToNearestState(lastDragVelocity) lastDragVelocity = 0f }, @@ -520,61 +703,212 @@ fun MediaPickerBottomSheet( // 🔥 Меняем высоту в реальном времени val newHeight = (sheetHeightPx.value - adjustedDragAmount) .coerceIn(minHeightPx, expandedHeightPx) - animationScope.launch { + dragSnapJob?.cancel() + dragSnapJob = animationScope.launch { sheetHeightPx.snapTo(newHeight) } } ) } ) { - // Drag handle + Column(modifier = Modifier.fillMaxSize()) { + // ═══════════════════════════════════════════════════════ + // TELEGRAM-STYLE HEADER: drag handle → collapsed title → expanded actionbar + // Like ChatAttachAlertPhotoLayout: actionBar alpha goes 0→1 on expand, + // dropdown shows album name + chevron, "N photos selected" on selection. + // ═══════════════════════════════════════════════════════ + + val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val currentAlbumTitle = + if (selectedAlbum?.isAllMedia != false) "Gallery" + else selectedAlbum?.name ?: "Gallery" + + val fullScreenHeaderProgress by animateFloatAsState( + targetValue = if (expandProgress > 0.94f) 1f else 0f, + animationSpec = tween(170, easing = FastOutSlowInEasing), + label = "gallery_fullscreen_header_progress" + ) + val collapsedHeaderAlpha = (1f - fullScreenHeaderProgress).coerceIn(0f, 1f) + val selectedCount = selectedItemOrder.size + + // ── Collapsed header: drag handle + selection text ── + // Like Telegram: when photos are selected, a "N photo(s) selected" + // label appears below the handle, pushing the grid down by 26dp. + + // Build selection text like Telegram: photo / video / media + val selectionHeaderText = remember(selectedItemOrder, mediaItems) { + val count = selectedItemOrder.size + if (count == 0) "" + else { + val byId = mediaItems.associateBy { it.id } + val selected = selectedItemOrder.mapNotNull { byId[it] } + val hasPhotos = selected.any { !it.isVideo } + val hasVideos = selected.any { it.isVideo } + when { + hasPhotos && hasVideos -> if (count == 1) "$count media selected" else "$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" + } + } + } + // Header height is DERIVED from how much the sheet has grown + // beyond collapsedStateHeightPx — perfectly in sync, no jerk. + val selectionHeaderHeightPx = if (!isExpanded) { + (sheetHeightPx.value - collapsedStateHeightPx) + .coerceIn(0f, selectionHeaderExtraPx) + } else 0f + val selectionHeaderHeightDp = with(density) { selectionHeaderHeightPx.toDp() } + val selectionHeaderAlpha = (selectionHeaderHeightPx / selectionHeaderExtraPx).coerceIn(0f, 1f) + Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { alpha = collapsedHeaderAlpha } ) { - Spacer(modifier = Modifier.height(8.dp)) + // Drag handle + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = Modifier + .width(36.dp) + .height(4.dp) + .graphicsLayer { alpha = handleAlpha } + .clip(RoundedCornerShape(2.dp)) + .background(secondaryTextColor.copy(alpha = 0.3f)) + ) + Spacer(modifier = Modifier.height(6.dp)) + } + + // "N photo(s) selected" — height derived from sheet growth, always in layout Box( modifier = Modifier - .width(36.dp) - .height(4.dp) - .clip(RoundedCornerShape(2.dp)) - .background(secondaryTextColor.copy(alpha = 0.3f)) - ) - Spacer(modifier = Modifier.height(8.dp)) - } - - // Header with action buttons - MediaPickerHeader( - selectedCount = selectedItems.size, - onDismiss = { animatedClose() }, - onSend = { - val selected = mediaItems.filter { it.id in selectedItems } - onMediaSelected(selected, pickerCaption.trim()) - animatedClose() - }, - isDarkTheme = isDarkTheme, - textColor = textColor - ) - - // Quick action buttons row (Camera, Gallery, File, Avatar, etc.) - QuickActionsRow( - isDarkTheme = isDarkTheme, - onCameraClick = { - hideKeyboard() - animatedClose { - hideKeyboard() - onOpenCamera() + .fillMaxWidth() + .height(selectionHeaderHeightDp) + .graphicsLayer { + alpha = selectionHeaderAlpha + clip = true + }, + contentAlignment = Alignment.CenterStart + ) { + if (selectionHeaderAlpha > 0.01f) { + Text( + text = selectionHeaderText, + color = textColor, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + modifier = Modifier.padding(start = 23.dp) + ) } - }, - onFileClick = { - animatedClose { onOpenFilePicker() } - }, - onAvatarClick = { - animatedClose { onAvatarClick() } } - ) - - Spacer(modifier = Modifier.height(8.dp)) + } + + // ── Expanded actionbar: statusbar padding + back + title ── + val fullScreenHeaderHeight = (statusBarTopPadding + 52.dp) * fullScreenHeaderProgress + Box( + modifier = Modifier + .fillMaxWidth() + .height(fullScreenHeaderHeight) + .graphicsLayer { clip = true } + ) { + if (fullScreenHeaderProgress > 0.001f) { + val headerTranslationY = with(density) { (1f - fullScreenHeaderProgress) * (-14).dp.toPx() } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = statusBarTopPadding, start = 4.dp, end = 8.dp) + .graphicsLayer { + alpha = fullScreenHeaderProgress + translationY = headerTranslationY + }, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = { requestClose() }, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Close gallery", + tint = textColor + ) + } + + // Crossfade between album dropdown and selection indicator + Box(modifier = Modifier.weight(1f)) { + androidx.compose.animation.AnimatedContent( + targetState = selectedCount > 0, + transitionSpec = { + fadeIn(tween(160)) togetherWith fadeOut(tween(160)) + }, + label = "expanded_header_title" + ) { hasSelection -> + if (hasSelection) { + Text( + text = when (selectedCount) { + 1 -> "1 photo selected" + else -> "$selectedCount photos selected" + }, + color = textColor, + fontSize = 19.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1 + ) + } else { + Row( + modifier = Modifier + .clip(RoundedCornerShape(18.dp)) + .clickable( + enabled = canSwitchAlbums, + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + if (canSwitchAlbums) showAlbumMenu = true + } + .padding(horizontal = 6.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = currentAlbumTitle, + color = textColor, + fontSize = 19.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1 + ) + if (canSwitchAlbums) { + Spacer(modifier = Modifier.width(2.dp)) + Icon( + imageVector = TablerIcons.ChevronDown, + contentDescription = null, + tint = textColor.copy(alpha = 0.7f), + modifier = Modifier.size(18.dp) + ) + } + } + } + } + + // Album dropdown (expanded mode) + AlbumDropdownMenu( + expanded = showAlbumMenu && canSwitchAlbums && fullScreenHeaderProgress > 0.5f, + onDismissRequest = { showAlbumMenu = false }, + albums = mediaAlbums, + selectedAlbumId = selectedAlbumId, + onAlbumSelected = { albumId -> + selectedAlbumId = albumId + showAlbumMenu = false + }, + isDarkTheme = isDarkTheme, + textColor = textColor, + secondaryTextColor = secondaryTextColor + ) + } + } + } + } // Content if (!hasPermission) { @@ -612,7 +946,7 @@ fun MediaPickerBottomSheet( modifier = Modifier.size(48.dp) ) } - } else if (mediaItems.isEmpty()) { + } else if (visibleMediaItems.isEmpty()) { // Empty state Box( modifier = Modifier @@ -640,112 +974,219 @@ fun MediaPickerBottomSheet( } else { // Media grid MediaGrid( - mediaItems = mediaItems, - selectedItems = selectedItems, + mediaItems = visibleMediaItems, + selectedItemOrder = selectedItemOrder, + showCameraItem = selectedAlbum?.isAllMedia != false, + gridState = mediaGridState, onCameraClick = { - hideKeyboard() - animatedClose { + requestClose { hideKeyboard() onOpenCamera() } }, - onItemClick = { item, _ -> - // Telegram-style selection: - // Tap toggles selection for both photos and videos. - if (item.id in selectedItems) { - selectedItems = selectedItems - item.id - } else if (selectedItems.size < maxSelection) { - selectedItems = selectedItems + item.id - } - }, - onItemLongClick = { item -> - // Long press keeps quick edit for photos. + onItemClick = { item, position -> if (!item.isVideo) { - thumbnailPosition = null + thumbnailPosition = position editingItem = item } else { - // Videos: keep long-press toggle behavior. - if (item.id in selectedItems) { - selectedItems = selectedItems - item.id - } else if (selectedItems.size < maxSelection) { - selectedItems = selectedItems + item.id - } + // Videos don't have photo editor in this flow. + toggleSelection(item.id) } }, + onItemCheckClick = { item -> + toggleSelection(item.id) + }, + onItemLongClick = { item -> + // Keep Telegram-like quick multiselect gesture. + toggleSelection(item.id) + }, isDarkTheme = isDarkTheme, modifier = Modifier.weight(1f) ) } - // Caption bar (видна когда есть выбранные фото) - AnimatedVisibility( - visible = selectedItems.isNotEmpty(), - enter = fadeIn() + expandVertically(expandFrom = Alignment.Bottom), - exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Bottom) + // Bottom actions (Telegram-style): anchored at the bottom and slide out when selection starts. + val bottomButtonsVisible = selectedItemOrder.isEmpty() && !isCaptionKeyboardVisible + val bottomButtonsHideProgress by animateFloatAsState( + targetValue = if (bottomButtonsVisible) 0f else 1f, + animationSpec = tween(180, easing = telegramButtonsEasing), + label = "bottom_buttons_hide_progress" + ) + val bottomButtonsFullHeight = 108.dp + val bottomButtonsHeight = bottomButtonsFullHeight * (1f - bottomButtonsHideProgress) + Box( + modifier = Modifier + .fillMaxWidth() + .height(bottomButtonsHeight) + .graphicsLayer { clip = true } ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - // Caption input (pill shape) - Row( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(20.dp)) - .background(if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFEFEFF0)) - .clickable { - // Программно открываем клавиатуру при клике на pill - captionEditTextView?.let { view -> - view.requestFocus() - val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) - } + if (bottomButtonsHideProgress < 0.999f) { + val rowOffsetPx = + with(density) { bottomButtonsFullHeight.toPx() * bottomButtonsHideProgress } + QuickActionsRow( + modifier = + Modifier.fillMaxWidth() + .graphicsLayer { + translationY = rowOffsetPx + alpha = 1f - bottomButtonsHideProgress + }, + isDarkTheme = isDarkTheme, + onImageClick = { + requestClose { + hideKeyboard() + onOpenCamera() } - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - // Emoji button - Icon( - painter = TelegramIcons.Smile, - contentDescription = "Emoji", - tint = if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Gray, - modifier = Modifier.size(22.dp) - ) + }, + onFileClick = { + requestClose { onOpenFilePicker() } + }, + onAvatarClick = { + requestClose { onAvatarClick() } + } + ) + } + } - // Caption text field + // Caption bar — Telegram-style: flat, full-width, same bg as sheet. + // No pill shape. Thin divider on top. Emoji button left, text field fills width. + // Right padding = 84dp to leave room for the floating send button. + // Bottom padding = 36dp so keyboard doesn't cover the input. + val captionBarHeight = 52.dp * captionBarProgress + Box( + modifier = Modifier + .fillMaxWidth() + .height(captionBarHeight) + .graphicsLayer { clip = true } + ) { + if (captionBarProgress > 0.001f) { + val captionOffsetPx = + with(density) { (1f - captionBarProgress) * 18.dp.toPx() } + Column( + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { + alpha = captionBarProgress + translationY = captionOffsetPx + } + ) { + // Thin divider line on top (like Telegram's 2dp shadow) Box( modifier = Modifier - .weight(1f) - .heightIn(min = 22.dp, max = 100.dp) + .fillMaxWidth() + .height(0.5.dp) + .background( + if (isDarkTheme) Color.White.copy(alpha = 0.08f) + else Color.Black.copy(alpha = 0.12f) + ) + ) + // Flat caption row — same background as sheet, no pill + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 6.dp, end = 84.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - AppleEmojiTextField( - value = pickerCaption, - onValueChange = { pickerCaption = it }, - textColor = if (isDarkTheme) Color.White else Color.Black, - textSize = 16f, - hint = "Add a caption...", - hintColor = if (isDarkTheme) Color.White.copy(alpha = 0.35f) else Color.Gray.copy(alpha = 0.5f), - modifier = Modifier.fillMaxWidth(), - requestFocus = false, - onViewCreated = { captionEditTextView = it }, - onFocusChanged = { } + // Emoji / Keyboard toggle button + val captionIconPainter = + if (isCaptionKeyboardVisible) TelegramIcons.Keyboard + else TelegramIcons.Smile + Icon( + painter = captionIconPainter, + contentDescription = "Caption keyboard toggle", + tint = + if (isDarkTheme) Color.White.copy(alpha = 0.55f) + else Color.Gray, + modifier = + Modifier + .size(32.dp) + .clip(CircleShape) + .clickable( + interactionSource = + remember { MutableInteractionSource() }, + indication = null + ) { + if (isCaptionKeyboardVisible) { + hideKeyboard() + } else { + focusCaptionInput() + } + } + .padding(4.dp) ) + + // Caption text field — no background, flat + Box( + modifier = Modifier + .weight(1f) + .heightIn(min = 28.dp, max = 120.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { focusCaptionInput() }, + contentAlignment = Alignment.CenterStart + ) { + AppleEmojiTextField( + value = pickerCaption, + onValueChange = { pickerCaption = it }, + textColor = if (isDarkTheme) Color.White else Color.Black, + textSize = 16f, + hint = "Add a caption...", + hintColor = + if (isDarkTheme) Color.White.copy(alpha = 0.35f) + else Color.Gray.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth(), + requestFocus = false, + onViewCreated = { captionEditTextView = it }, + onFocusChanged = { } + ) + } } } + } + } + } // end Column - // Send button + // ═══════════════════════════════════════════════════════ + // Floating send button (Telegram-style: 56dp blue circle) + // Positioned at bottom-end, overlapping the caption bar + // — exactly like ChatAttachAlert.writeButton (110x110 container, 64dp circle) + // ═══════════════════════════════════════════════════════ + val sendButtonVisible = selectedItemOrder.isNotEmpty() + val sendButtonProgress by animateFloatAsState( + targetValue = if (sendButtonVisible) 1f else 0f, + animationSpec = tween(180, easing = FastOutSlowInEasing), + label = "send_button_progress" + ) + if (sendButtonProgress > 0.001f) { + val sendScale = 0.2f + 0.8f * sendButtonProgress + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 14.dp, bottom = 8.dp) + .graphicsLayer { + scaleX = sendScale + scaleY = sendScale + alpha = sendButtonProgress + }, + contentAlignment = Alignment.Center + ) { + // The circle button Box( modifier = Modifier - .size(42.dp) + .size(56.dp) + .shadow( + elevation = 6.dp, + shape = CircleShape, + clip = false + ) .clip(CircleShape) .background(PrimaryBlue) .clickable { - val selected = mediaItems.filter { it.id in selectedItems } - onMediaSelected(selected, pickerCaption.trim()) + val selected = resolveSelectedMedia() + val caption = pickerCaption.trim() + resetPickerCaptionInput() + onMediaSelected(selected, caption) animatedClose() }, contentAlignment = Alignment.Center @@ -754,117 +1195,133 @@ fun MediaPickerBottomSheet( painter = TelegramIcons.Send, contentDescription = "Send", tint = Color.White, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(24.dp) ) } + // Counter badge (Telegram-style cutout ring on the button) + if (selectedItemOrder.size > 0) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = 4.dp, y = (-2).dp) + .sizeIn(minWidth = 22.dp, minHeight = 22.dp) + .border( + width = 2.dp, + color = backgroundColor, + shape = CircleShape + ) + .clip(CircleShape) + .background(PrimaryBlue) + .padding(horizontal = 4.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = selectedItemOrder.size.toString(), + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + maxLines = 1 + ) + } + } } } - } + } // end Box wrapper } } } + + if (showDiscardSelectionDialog) { + AlertDialog( + onDismissRequest = { + showDiscardSelectionDialog = false + pendingDismissAfterConfirm = null + }, + containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, + title = { + Text( + text = "Discard selection?", + fontWeight = FontWeight.SemiBold, + color = textColor + ) + }, + text = { + Text( + text = "Selected photos will be removed.", + color = secondaryTextColor, + fontSize = 15.sp + ) + }, + dismissButton = { + TextButton( + onClick = { + showDiscardSelectionDialog = false + pendingDismissAfterConfirm = null + } + ) { + Text("Cancel", color = PrimaryBlue) + } + }, + confirmButton = { + TextButton( + onClick = { + val afterClose = pendingDismissAfterConfirm + showDiscardSelectionDialog = false + pendingDismissAfterConfirm = null + selectedItemOrder = emptyList() + resetPickerCaptionInput() + animatedClose(afterClose) + } + ) { + Text("Discard", color = Color(0xFFFF3B30)) + } + } + ) + } // Image Editor FULLSCREEN overlay для фото из галереи - if (editingItem != null) { - // 🎨 Чёрный статус бар для ImageEditor - DisposableEffect(Unit) { - val window = (view.context as? android.app.Activity)?.window - val originalStatusBarColor = window?.statusBarColor ?: 0 - window?.statusBarColor = android.graphics.Color.BLACK - - onDispose { - window?.statusBarColor = originalStatusBarColor - } - } - - // Используем Dialog для полного перекрытия экрана включая статус бар - Dialog( - onDismissRequest = { + // ImageEditorScreen wraps itself in a Dialog internally — no external wrapper needed + editingItem?.let { item -> + ImageEditorScreen( + imageUri = item.uri, + onDismiss = { editingItem = null thumbnailPosition = null shouldShow = true }, - properties = DialogProperties( - dismissOnBackPress = true, - dismissOnClickOutside = false, - usePlatformDefaultWidth = false, - decorFitsSystemWindows = false // Позволяет рисовать под статус баром - ) - ) { - // Делаем статус бар прозрачным и рисуем под ним - val dialogView = LocalView.current - LaunchedEffect(Unit) { - val window = (dialogView.context as? android.app.Activity)?.window - ?: (dialogView.parent as? android.view.View)?.context?.let { - (it as? android.app.Activity)?.window - } - // Для Dialog нужно получить window диалога - val dialogWindow = (dialogView.parent as? android.view.View)?.let { - var v: android.view.View? = it - while (v != null) { - if (v.context is android.app.Activity) { - break - } - v = v.parent as? android.view.View - } - (v?.context as? android.app.Activity)?.window - } - } - - // Fullscreen black background + content - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black) - .systemBarsPadding() // Отступы от системных баров - ) { - editingItem?.let { item -> - ImageEditorScreen( - imageUri = item.uri, - onDismiss = { - editingItem = null - thumbnailPosition = null - // Возвращаем галерею обратно - shouldShow = true - }, - onSave = { editedUri -> - editingItem = null - thumbnailPosition = null - // Если нет onMediaSelectedWithCaption - открываем preview - if (onMediaSelectedWithCaption == null) { - previewPhotoUri = editedUri - } else { - // Отправляем без caption (если нажали Done вместо Send) - val mediaItem = MediaItem( - id = System.currentTimeMillis(), - uri = editedUri, - mimeType = "image/png", - dateModified = System.currentTimeMillis() - ) - onMediaSelected(listOf(mediaItem), "") - onDismiss() - } - }, - onSaveWithCaption = if (onMediaSelectedWithCaption != null) { editedUri, caption -> - editingItem = null - thumbnailPosition = null - val mediaItem = MediaItem( - id = System.currentTimeMillis(), - uri = editedUri, - mimeType = "image/png", - dateModified = System.currentTimeMillis() - ) - onMediaSelectedWithCaption(mediaItem, caption) - onDismiss() - } else null, - isDarkTheme = isDarkTheme, - showCaptionInput = onMediaSelectedWithCaption != null, - recipientName = recipientName, - thumbnailPosition = thumbnailPosition + onSave = { editedUri -> + editingItem = null + thumbnailPosition = null + if (onMediaSelectedWithCaption == null) { + previewPhotoUri = editedUri + } else { + val mediaItem = MediaItem( + id = System.currentTimeMillis(), + uri = editedUri, + mimeType = "image/png", + dateModified = System.currentTimeMillis() ) + onMediaSelected(listOf(mediaItem), "") + onDismiss() } - } - } + }, + onSaveWithCaption = if (onMediaSelectedWithCaption != null) { editedUri, caption -> + editingItem = null + thumbnailPosition = null + val mediaItem = MediaItem( + id = System.currentTimeMillis(), + uri = editedUri, + mimeType = "image/png", + dateModified = System.currentTimeMillis() + ) + onMediaSelectedWithCaption(mediaItem, caption) + onDismiss() + } else null, + isDarkTheme = isDarkTheme, + showCaptionInput = onMediaSelectedWithCaption != null, + recipientName = recipientName, + thumbnailPosition = thumbnailPosition + ) } // Image Editor overlay для фото с камеры @@ -936,105 +1393,138 @@ fun MediaPickerBottomSheet( } } +/** + * Telegram-style album dropdown menu. + * Mirrors ChatAttachAlertPhotoLayout's updateAlbumsDropDown() + AlbumButton items. + * Each row: cover thumbnail · album name · item count. + */ @Composable -private fun MediaPickerHeader( - selectedCount: Int, - onDismiss: () -> Unit, - onSend: () -> Unit, +private fun AlbumDropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + albums: List, + selectedAlbumId: Long, + onAlbumSelected: (Long) -> Unit, isDarkTheme: Boolean, - textColor: Color + textColor: Color, + secondaryTextColor: Color ) { - Row( + val menuBg = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White + + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically + .background(menuBg) + .widthIn(min = 220.dp, max = 300.dp) ) { - // Title - Text( - text = if (selectedCount > 0) "$selectedCount selected" else "Gallery", - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, - color = textColor, - modifier = Modifier.weight(1f) - ) - - // Send button (visible when items selected) - AnimatedVisibility( - visible = selectedCount > 0, - enter = fadeIn() + scaleIn(), - exit = fadeOut() + scaleOut() - ) { - Button( - onClick = onSend, - colors = ButtonDefaults.buttonColors( - containerColor = PrimaryBlue - ), - shape = RoundedCornerShape(20.dp), - contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp) - ) { - Icon( - painter = TelegramIcons.Send, - contentDescription = "Send", - modifier = Modifier.size(18.dp), - tint = Color.White - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = "Send", - color = Color.White, - fontWeight = FontWeight.Medium - ) - } + albums.forEach { album -> + val isSelected = album.id == selectedAlbumId + val coverUri = album.items.firstOrNull()?.uri + + DropdownMenuItem( + text = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Cover thumbnail (like Telegram's AlbumButton) + Box( + modifier = Modifier + .size(32.dp) + .clip(RoundedCornerShape(4.dp)) + .background(if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE5E5EA)) + ) { + if (coverUri != null) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(coverUri) + .crossfade(false) + .size(80) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + } + + // Album name + count + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (album.isAllMedia) "All media" else album.name, + color = textColor, + fontSize = 15.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1 + ) + Text( + text = album.items.size.toString(), + color = secondaryTextColor, + fontSize = 12.sp, + maxLines = 1 + ) + } + + // Checkmark for selected album + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(18.dp) + ) + } + } + }, + onClick = { onAlbumSelected(album.id) }, + modifier = Modifier.height(48.dp) + ) } } } @Composable private fun QuickActionsRow( + modifier: Modifier = Modifier, isDarkTheme: Boolean, - onCameraClick: () -> Unit, + onImageClick: () -> Unit, onFileClick: () -> Unit, onAvatarClick: () -> Unit ) { - val buttonColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) - val iconColor = if (isDarkTheme) Color.White else Color.Black - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) + modifier = + modifier + .fillMaxWidth() + .padding(start = 28.dp, end = 28.dp, top = 8.dp, bottom = 12.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.Bottom ) { - // Camera button QuickActionButton( - icon = TelegramIcons.Camera, - label = "Camera", - backgroundColor = PrimaryBlue, - iconColor = Color.White, - onClick = onCameraClick, - animationDelay = 0 + icon = TelegramIcons.Photos, + contentDescription = "Gallery", + label = "Gallery", + isActive = true, + isDarkTheme = isDarkTheme, + animationDelayMs = 0, + onClick = onImageClick ) - - // Avatar button - QuickActionButton( - icon = TelegramIcons.Contact, - label = "Avatar", - backgroundColor = buttonColor, - iconColor = iconColor, - onClick = onAvatarClick, - animationDelay = 50 - ) - - // File button QuickActionButton( icon = TelegramIcons.File, + contentDescription = "File", label = "File", - backgroundColor = buttonColor, - iconColor = iconColor, - onClick = onFileClick, - animationDelay = 100 + isDarkTheme = isDarkTheme, + animationDelayMs = 32, + onClick = onFileClick + ) + QuickActionButton( + icon = TelegramIcons.Contact, + contentDescription = "Avatar", + label = "Avatar", + isDarkTheme = isDarkTheme, + animationDelayMs = 64, + onClick = onAvatarClick ) } } @@ -1042,56 +1532,72 @@ private fun QuickActionsRow( @Composable private fun QuickActionButton( icon: androidx.compose.ui.graphics.painter.Painter, + contentDescription: String, label: String, - backgroundColor: Color, - iconColor: Color, - onClick: () -> Unit, - animationDelay: Int = 0 + isDarkTheme: Boolean, + isActive: Boolean = false, + animationDelayMs: Int, + onClick: () -> Unit ) { - // Bounce animation for icon - val iconScale = remember { Animatable(0f) } + val scale = remember { Animatable(0f) } + val alpha = remember { Animatable(0f) } + val easeOut = remember { CubicBezierEasing(0f, 0f, 0.58f, 1f) } // Telegram EASE_OUT + val easeIn = remember { CubicBezierEasing(0.42f, 0f, 1f, 1f) } // Telegram EASE_IN LaunchedEffect(Unit) { - kotlinx.coroutines.delay(animationDelay.toLong()) - iconScale.animateTo( - targetValue = 1f, - animationSpec = spring( - dampingRatio = 0.5f, // Bouncy effect - stiffness = 400f - ) - ) + delay(animationDelayMs.toLong()) + alpha.snapTo(0f) + scale.snapTo(0f) + coroutineScope { + launch { + alpha.animateTo(1f, animationSpec = tween(200, easing = easeOut)) + } + launch { + scale.animateTo(1.1f, animationSpec = tween(200, easing = easeOut)) + scale.animateTo(1f, animationSpec = tween(100, easing = easeIn)) + } + } } Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onClick - ) + modifier = Modifier.width(72.dp).graphicsLayer { this.alpha = alpha.value }, + horizontalAlignment = Alignment.CenterHorizontally ) { Box( - modifier = Modifier - .size(56.dp) - .graphicsLayer { - scaleX = iconScale.value - scaleY = iconScale.value - } - .clip(CircleShape) - .background(backgroundColor), + modifier = + Modifier.size(56.dp) + .graphicsLayer { + scaleX = scale.value + scaleY = scale.value + } + .clip(CircleShape) + .background(PrimaryBlue) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick + ), contentAlignment = Alignment.Center ) { Icon( painter = icon, - contentDescription = label, - tint = iconColor, + contentDescription = contentDescription, + tint = Color.White, modifier = Modifier.size(24.dp) ) } - Spacer(modifier = Modifier.height(4.dp)) + + Spacer(modifier = Modifier.height(6.dp)) Text( text = label, - fontSize = 12.sp, - color = iconColor.copy(alpha = 0.8f) + fontSize = 18.sp * 0.72f, + fontWeight = FontWeight.Medium, + color = + when { + isActive -> PrimaryBlue + isDarkTheme -> Color(0xFF9AA1AB) + else -> Color(0xFF7A8190) + }, + maxLines = 1 ) } } @@ -1099,29 +1605,37 @@ private fun QuickActionButton( @Composable private fun MediaGrid( mediaItems: List, - selectedItems: Set, + selectedItemOrder: List, + showCameraItem: Boolean = true, + gridState: LazyGridState = rememberLazyGridState(), onCameraClick: () -> Unit, onItemClick: (MediaItem, ThumbnailPosition) -> Unit, + onItemCheckClick: (MediaItem) -> Unit, onItemLongClick: (MediaItem) -> Unit, isDarkTheme: Boolean, modifier: Modifier = Modifier ) { - val gridState = rememberLazyGridState() + val selectionIndexById = + remember(selectedItemOrder) { + selectedItemOrder.withIndex().associate { (index, id) -> id to (index + 1) } + } LazyVerticalGrid( columns = GridCells.Fixed(3), state = gridState, modifier = modifier.fillMaxWidth(), - contentPadding = PaddingValues(2.dp), - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) + contentPadding = PaddingValues(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { - // 📷 Camera button as first item - item(key = "camera_button") { - CameraGridItem( - onClick = onCameraClick, - isDarkTheme = isDarkTheme - ) + // Camera button as first item (only in All Media album, like Telegram) + if (showCameraItem) { + item(key = "camera_button") { + CameraGridItem( + onClick = onCameraClick, + isDarkTheme = isDarkTheme + ) + } } // Media items @@ -1129,13 +1643,13 @@ private fun MediaGrid( items = mediaItems, key = { it.id } ) { item -> + val selectionIndex = selectionIndexById[item.id] ?: 0 MediaGridItem( item = item, - isSelected = item.id in selectedItems, - selectionIndex = if (item.id in selectedItems) { - selectedItems.toList().indexOf(item.id) + 1 - } else 0, - onClick = { position -> onItemClick(item, position) }, + isSelected = selectionIndex > 0, + selectionIndex = selectionIndex, + onContentClick = { position -> onItemClick(item, position) }, + onCheckClick = { onItemCheckClick(item) }, onLongClick = { onItemLongClick(item) }, isDarkTheme = isDarkTheme ) @@ -1199,6 +1713,7 @@ private fun CameraGridItem( factory = { ctx -> val previewView = PreviewView(ctx).apply { scaleType = PreviewView.ScaleType.FILL_CENTER + implementationMode = PreviewView.ImplementationMode.COMPATIBLE } val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) @@ -1282,11 +1797,57 @@ private fun MediaGridItem( item: MediaItem, isSelected: Boolean, selectionIndex: Int, - onClick: (ThumbnailPosition) -> Unit, + onContentClick: (ThumbnailPosition) -> Unit, + onCheckClick: () -> Unit, onLongClick: () -> Unit, isDarkTheme: Boolean ) { val context = LocalContext.current + val selectedTransition = updateTransition(targetState = isSelected, label = "mediaSelection") + val itemScale by selectedTransition.animateFloat( + transitionSpec = { tween(durationMillis = 200, easing = FastOutSlowInEasing) }, + label = "itemScale" + ) { selected -> + if (selected) 0.787f else 1f + } + val tileBackgroundColor by animateColorAsState( + targetValue = + if (isSelected) { + if (isDarkTheme) Color(0xFF2F2F31) else Color(0xFFE8EDF5) + } else { + Color.Transparent + }, + animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing), + label = "tileBackgroundColor" + ) + val checkBackgroundColor by animateColorAsState( + targetValue = if (isSelected) PrimaryBlue else Color(0xCCFFFFFF), + animationSpec = tween(durationMillis = 170, easing = FastOutSlowInEasing), + label = "checkBackgroundColor" + ) + val checkBorderColor by animateColorAsState( + targetValue = if (isSelected) PrimaryBlue else Color(0x80B0B0B0), + animationSpec = tween(durationMillis = 170, easing = FastOutSlowInEasing), + label = "checkBorderColor" + ) + val checkContentAlpha by animateFloatAsState( + targetValue = if (isSelected) 1f else 0f, + animationSpec = tween(durationMillis = 140, easing = FastOutSlowInEasing), + label = "checkContentAlpha" + ) + val checkScale = remember { Animatable(1f) } + LaunchedEffect(isSelected) { + if (isSelected) { + checkScale.snapTo(0.72f) + checkScale.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ) + ) + } + } // 📍 Отслеживаем позицию для анимации var itemPosition by remember { mutableStateOf(null) } @@ -1295,6 +1856,7 @@ private fun MediaGridItem( modifier = Modifier .aspectRatio(1f) .clip(RoundedCornerShape(4.dp)) + .background(tileBackgroundColor) .onGloballyPositioned { coordinates -> val positionInWindow = coordinates.positionInWindow() itemPosition = ThumbnailPosition( @@ -1305,55 +1867,61 @@ private fun MediaGridItem( cornerRadius = 4f ) } - .pointerInput(Unit) { - detectTapGestures( - onTap = { - itemPosition?.let { onClick(it) } - }, - onLongPress = { onLongClick() } - ) - } ) { - // Thumbnail - AsyncImage( - model = ImageRequest.Builder(context) - .data(item.uri) - .crossfade(true) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) - - // Video duration overlay - if (item.isVideo && item.duration > 0) { - Box( - modifier = Modifier - .align(Alignment.BottomStart) - .padding(4.dp) - .background( - Color.Black.copy(alpha = 0.6f), - RoundedCornerShape(4.dp) - ) - .padding(horizontal = 4.dp, vertical = 2.dp) - ) { - Text( - text = formatDuration(item.duration), - color = Color.White, - fontSize = 11.sp, - fontWeight = FontWeight.Medium - ) - } - } - - // Selection overlay Box( - modifier = Modifier - .fillMaxSize() - .background( - if (isSelected) PrimaryBlue.copy(alpha = 0.3f) else Color.Transparent - ) - ) + modifier = + Modifier.fillMaxSize() + .pointerInput(Unit) { + detectTapGestures( + onTap = { + itemPosition?.let { onContentClick(it) } + }, + onLongPress = { onLongClick() } + ) + } + .graphicsLayer { + scaleX = itemScale + scaleY = itemScale + } + .clip(RoundedCornerShape(4.dp)) + ) { + // Thumbnail + val imageRequest = + remember(item.uri) { + ImageRequest.Builder(context) + .data(item.uri) + .crossfade(false) + .build() + } + AsyncImage( + model = imageRequest, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + + // Video duration overlay + if (item.isVideo && item.duration > 0) { + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(4.dp) + .background( + Color.Black.copy(alpha = 0.6f), + RoundedCornerShape(4.dp) + ) + .padding(horizontal = 4.dp, vertical = 2.dp) + ) { + Text( + text = formatDuration(item.duration), + color = Color.White, + fontSize = 11.sp, + fontWeight = FontWeight.Medium + ) + } + } + + } // Selection checkbox Box( @@ -1361,23 +1929,30 @@ private fun MediaGridItem( .align(Alignment.TopEnd) .padding(6.dp) .size(24.dp) + .graphicsLayer { + scaleX = checkScale.value + scaleY = checkScale.value + } .clip(CircleShape) - .background( - if (isSelected) PrimaryBlue else Color.White.copy(alpha = 0.8f) - ) + .background(checkBackgroundColor) .border( width = 2.dp, - color = if (isSelected) PrimaryBlue else Color.Gray.copy(alpha = 0.5f), + color = checkBorderColor, shape = CircleShape + ) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onCheckClick ), contentAlignment = Alignment.Center ) { - if (isSelected) { - Icon( - painter = TelegramIcons.Done, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(16.dp) + if (selectionIndex > 0) { + Text( + text = selectionIndex.toString(), + color = Color.White.copy(alpha = checkContentAlpha), + fontSize = 12.sp, + fontWeight = FontWeight.Bold ) } } @@ -1435,36 +2010,51 @@ private fun PermissionRequestView( } /** - * Load media items from device gallery + * Result of loading media from device gallery */ -private suspend fun loadMediaItems(context: Context): List = withContext(Dispatchers.IO) { +data class MediaPickerData( + val items: List, + val albums: List +) + +/** + * Load 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 + */ +private suspend fun loadMediaPickerData(context: Context): MediaPickerData = withContext(Dispatchers.IO) { val items = mutableListOf() try { - // Query images + // 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.DATE_MODIFIED, + MediaStore.Images.Media.BUCKET_ID, + MediaStore.Images.Media.BUCKET_DISPLAY_NAME ) - val imageSortOrder = "${MediaStore.Images.Media.DATE_MODIFIED} DESC" - context.contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, imageProjection, null, null, - imageSortOrder + "${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, @@ -1475,38 +2065,44 @@ private suspend fun loadMediaItems(context: Context): List = withCont id = id, uri = uri, mimeType = mimeType, - dateModified = dateModified + dateModified = dateModified, + bucketId = bucketId, + bucketName = bucketName )) } } - // Query videos + // 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.DATE_MODIFIED, + MediaStore.Video.Media.BUCKET_ID, + MediaStore.Video.Media.BUCKET_DISPLAY_NAME ) - val videoSortOrder = "${MediaStore.Video.Media.DATE_MODIFIED} DESC" - context.contentResolver.query( MediaStore.Video.Media.EXTERNAL_CONTENT_URI, videoProjection, null, null, - videoSortOrder + "${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, @@ -1519,7 +2115,9 @@ private suspend fun loadMediaItems(context: Context): List = withCont uri = uri, mimeType = mimeType, duration = duration, - dateModified = dateModified + dateModified = dateModified, + bucketId = bucketId, + bucketName = bucketName )) } } @@ -1528,9 +2126,31 @@ private suspend fun loadMediaItems(context: Context): List = withCont items.sortByDescending { it.dateModified } } catch (e: Exception) { + Log.e(TAG, "Failed to load media", e) } - - items + + // 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) } /** @@ -1778,10 +2398,15 @@ fun PhotoPreviewWithCaptionScreen( ) } - // Send button + // Send button (Telegram-style) Box( modifier = Modifier - .size(36.dp) + .size(44.dp) + .shadow( + elevation = 4.dp, + shape = CircleShape, + clip = false + ) .clip(CircleShape) .background(PrimaryBlue) .clickable { onSend() }, @@ -1792,7 +2417,7 @@ fun PhotoPreviewWithCaptionScreen( contentDescription = "Send", tint = Color.White, modifier = Modifier - .size(22.dp) + .size(24.dp) .offset(x = 1.dp) ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt index 4db16c5..4b8c06a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt @@ -79,6 +79,7 @@ fun AvatarImage( displayName: String? = null // 🔥 Имя для инициалов (title/username) ) { 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() @@ -141,6 +142,13 @@ fun AvatarImage( modifier = Modifier.fillMaxSize(), 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) { // Отображаем реальный аватар Image( diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt b/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt index 8d16c43..de93bb1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt @@ -42,61 +42,7 @@ fun BoxScope.BlurredAvatarBackground( overlayColors: List? = null, isDarkTheme: Boolean = true ) { - // Если выбран цвет в Appearance — рисуем блюр аватарки + полупрозрачный 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(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 аватарки + // Загрузка и blur аватарки — нужна ВСЕГДА (и для overlay-цветов, и для обычного режима) val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState() ?: 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) { Image( bitmap = blurredBitmap!!.asImageBitmap(), contentDescription = null, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.matchParentSize() + .graphicsLayer { this.alpha = 0.35f }, 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) } } - diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt index 5256f18..9c7aa53 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt @@ -99,35 +99,42 @@ fun SwipeBackContainer( val scrimAlpha = (1f - (currentOffset / screenWidthPx).coerceIn(0f, 1f)) * 0.4f // 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) { if (isVisible && !shouldShow) { // Animate in: fade-in shouldShow = true isAnimatingIn = true - offsetAnimatable.snapTo(0f) // No slide for entry - alphaAnimatable.snapTo(0f) - alphaAnimatable.animateTo( - targetValue = 1f, - animationSpec = - tween( - durationMillis = ANIMATION_DURATION_ENTER, - easing = FastOutSlowInEasing - ) - ) - isAnimatingIn = false - } else if (!isVisible && shouldShow && !isAnimatingOut) { + try { + offsetAnimatable.snapTo(0f) // No slide for entry + alphaAnimatable.snapTo(0f) + alphaAnimatable.animateTo( + targetValue = 1f, + animationSpec = + tween( + durationMillis = ANIMATION_DURATION_ENTER, + easing = FastOutSlowInEasing + ) + ) + } finally { + isAnimatingIn = false + } + } else if (!isVisible && shouldShow) { // Animate out: fade-out (when triggered by button, not swipe) isAnimatingOut = true - alphaAnimatable.snapTo(1f) - alphaAnimatable.animateTo( - targetValue = 0f, - animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing) - ) - shouldShow = false - isAnimatingOut = false - offsetAnimatable.snapTo(0f) - alphaAnimatable.snapTo(0f) - dragOffset = 0f + try { + alphaAnimatable.animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing) + ) + } finally { + shouldShow = false + isAnimatingOut = false + dragOffset = 0f + } } } @@ -286,6 +293,13 @@ fun SwipeBackContainer( 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() } else { offsetAnimatable.animateTo( @@ -298,9 +312,8 @@ fun SwipeBackContainer( TelegramEasing ) ) + dragOffset = 0f } - - dragOffset = 0f } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/VerifiedBadge.kt b/app/src/main/java/com/rosetta/messenger/ui/components/VerifiedBadge.kt index 6278dc2..363b08b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/VerifiedBadge.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/VerifiedBadge.kt @@ -28,18 +28,21 @@ fun VerifiedBadge( verified: Int, size: Int = 16, modifier: Modifier = Modifier, - isDarkTheme: Boolean = isSystemInDarkTheme() + isDarkTheme: Boolean = isSystemInDarkTheme(), + badgeTint: Color? = null ) { if (verified <= 0) return var showDialog by remember { mutableStateOf(false) } // Цвет в зависимости от уровня верификации - val badgeColor = when (verified) { - 1 -> Color(0xFF1DA1F2) // Стандартная верификация (синий как в Twitter/Telegram) - 2 -> Color(0xFFFFD700) // Золотая верификация - else -> Color(0xFF4CAF50) // Зеленая для других уровней - } + val badgeColor = + badgeTint + ?: when (verified) { + 1 -> Color(0xFF1DA1F2) // Стандартная верификация (синий как в Twitter/Telegram) + 2 -> Color(0xFFFFD700) // Золотая верификация + else -> Color(0xFF4CAF50) // Зеленая для других уровней + } // Текст аннотации val annotationText = when (verified) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt index e0aad45..c03a1ab 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt @@ -1,10 +1,17 @@ package com.rosetta.messenger.ui.settings 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.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -24,23 +31,34 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale 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.Color +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.layout.ContentScale +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalView 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.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.utils.AvatarFileManager import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import compose.icons.tablericons.Sun 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) } + // ── Circular reveal state ── + val scope = rememberCoroutineScope() + var screenshotBitmap by remember { mutableStateOf(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() } - Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { screenSize = it.size } + ) { Column( modifier = Modifier .fillMaxSize() @@ -119,7 +213,18 @@ fun AppearanceScreen( 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( imageVector = if (isDarkTheme) TablerIcons.Sun else TablerIcons.Moon, contentDescription = "Toggle theme", @@ -149,9 +254,13 @@ fun AppearanceScreen( ColorSelectionGrid( selectedId = selectedId, isDarkTheme = isDarkTheme, - onSelect = { id -> - selectedId = id - onBlurColorChange(id) + onSelect = { id, centerInRoot -> + if (id != selectedId) { + triggerCircularReveal(centerInRoot) { + selectedId = id + onBlurColorChange(id) + } + } } ) @@ -168,6 +277,37 @@ fun AppearanceScreen( 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( selectedId: String, isDarkTheme: Boolean, - onSelect: (String) -> Unit + onSelect: (String, Offset) -> Unit ) { val allOptions = BackgroundBlurPresets.allWithDefault val horizontalPadding = 12.dp @@ -460,7 +600,7 @@ private fun ColorSelectionGrid( isSelected = option.id == selectedId, isDarkTheme = isDarkTheme, circleSize = circleSize, - onClick = { onSelect(option.id) } + onSelectWithPosition = { centerInRoot -> onSelect(option.id, centerInRoot) } ) } repeat(columns - rowItems.size) { @@ -478,7 +618,7 @@ private fun ColorCircleItem( isSelected: Boolean, isDarkTheme: Boolean, circleSize: Dp, - onClick: () -> Unit + onSelectWithPosition: (Offset) -> Unit ) { val scale by animateFloatAsState( targetValue = if (isSelected) 1.08f else 1.0f, @@ -496,17 +636,26 @@ private fun ColorCircleItem( label = "border" ) + // Track center position in root coordinates for circular reveal + var boundsInRoot by remember { mutableStateOf(Rect.Zero) } + Box( modifier = Modifier .size(circleSize) .scale(scale) + .onGloballyPositioned { coords -> + boundsInRoot = coords.boundsInRoot() + } .clip(CircleShape) .border( 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), shape = CircleShape ) - .clickable(onClick = onClick), + .clickable { + val center = boundsInRoot.center + onSelectWithPosition(center) + }, contentAlignment = Alignment.Center ) { when { diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index 6659927..c0d6c0b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -201,6 +201,9 @@ fun OtherProfileScreen( val isSafetyProfile = remember(user.publicKey) { user.publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY } + val isSystemAccount = remember(user.publicKey) { + MessageRepository.isSystemAccount(user.publicKey) + } val context = LocalContext.current val view = LocalView.current val window = remember { (view.context as? Activity)?.window } @@ -590,7 +593,7 @@ fun OtherProfileScreen( ) } - if (!isSafetyProfile) { + if (!isSafetyProfile && !isSystemAccount) { // Call Button( onClick = { /* TODO: call action */ }, @@ -791,6 +794,7 @@ fun OtherProfileScreen( showAvatarMenu = showAvatarMenu, onAvatarMenuChange = { showAvatarMenu = it }, isBlocked = isBlocked, + isSystemAccount = isSystemAccount, onBlockToggle = { coroutineScope.launch { if (isBlocked) { @@ -1727,7 +1731,7 @@ private fun OtherProfileEmptyState( @Composable private fun CollapsingOtherProfileHeader( name: String, - @Suppress("UNUSED_PARAMETER") username: String, + username: String, publicKey: String, verified: Int, isOnline: Boolean, @@ -1740,6 +1744,7 @@ private fun CollapsingOtherProfileHeader( showAvatarMenu: Boolean, onAvatarMenuChange: (Boolean) -> Unit, isBlocked: Boolean, + isSystemAccount: Boolean = false, onBlockToggle: () -> Unit, avatarRepository: AvatarRepository? = null, onClearChat: () -> Unit, @@ -1769,6 +1774,10 @@ private fun CollapsingOtherProfileHeader( 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 rosettaBadgeBlue = Color(0xFF1DA1F2) + val isRosettaOfficial = + name.equals("Rosetta", ignoreCase = true) || + username.equals("rosetta", ignoreCase = true) // ═══════════════════════════════════════════════════════════ // 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой @@ -1878,6 +1887,13 @@ private fun CollapsingOtherProfileHeader( modifier = Modifier.fillMaxSize(), 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) { OtherProfileFullSizeAvatar( publicKey = publicKey, @@ -1975,6 +1991,7 @@ private fun CollapsingOtherProfileHeader( onDismiss = { onAvatarMenuChange(false) }, isDarkTheme = isDarkTheme, isBlocked = isBlocked, + isSystemAccount = isSystemAccount, onBlockClick = { onAvatarMenuChange(false) onBlockToggle() @@ -2010,9 +2027,14 @@ private fun CollapsingOtherProfileHeader( textAlign = TextAlign.Center ) - if (verified > 0) { + if (verified > 0 || isRosettaOfficial) { 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 + ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index 6250db2..6a03420 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -61,6 +61,7 @@ import com.rosetta.messenger.biometric.BiometricAvailability import com.rosetta.messenger.biometric.BiometricPreferences import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.components.BlurredAvatarBackground +import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlueDark @@ -1067,7 +1068,7 @@ fun ProfileScreen( @Composable private fun CollapsingProfileHeader( name: String, - @Suppress("UNUSED_PARAMETER") username: String, + username: String, publicKey: String, avatarColors: AvatarColors, collapseProgress: Float, @@ -1128,11 +1129,17 @@ private fun CollapsingProfileHeader( // Font sizes 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 rosettaBadgeBlue = Color(0xFF1DA1F2) + val isRosettaOfficial = + name.equals("Rosetta", ignoreCase = true) || + username.equals("rosetta", ignoreCase = true) Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) { // Expansion fraction — computed early so gradient can fade during expansion val expandFraction = expansionProgress.coerceIn(0f, 1f) 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 — ВСЕГДА видим @@ -1140,15 +1147,16 @@ private fun CollapsingProfileHeader( // и естественно перекрывает его. Без мерцания. // ═══════════════════════════════════════════════════════════ Box(modifier = Modifier.matchParentSize()) { - Box(modifier = Modifier.matchParentSize().background(headerBaseColor)) if (backgroundBlurColorId == "none") { // None — стандартный цвет шапки без blur Box( modifier = Modifier - .fillMaxSize() + .matchParentSize() .background(headerBaseColor) ) } else { + // Neutral base so transparent blur layers don't pick up blue tint + Box(modifier = Modifier.matchParentSize().background(screenBgColor)) BlurredAvatarBackground( publicKey = publicKey, avatarRepository = avatarRepository, @@ -1375,16 +1383,30 @@ private fun CollapsingProfileHeader( Modifier.align(Alignment.TopCenter).offset(y = textY), horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - text = name, - fontSize = nameFontSize, - fontWeight = FontWeight.SemiBold, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.widthIn(max = 220.dp), - textAlign = TextAlign.Center - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = name, + fontSize = nameFontSize, + fontWeight = FontWeight.SemiBold, + 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)) diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt index d49b25c..2916977 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt @@ -1,11 +1,14 @@ package com.rosetta.messenger.ui.settings 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.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape @@ -20,13 +23,42 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.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.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalView 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.sp +import androidx.core.view.drawToBitmap 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 fun ThemeScreen( @@ -49,123 +81,251 @@ fun ThemeScreen( val surfaceColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) + val scope = rememberCoroutineScope() + val systemIsDark = isSystemInDarkTheme() // Theme mode: "light", "dark", "auto" 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(null) } + var lightOptionCenter by remember { mutableStateOf(null) } + var darkOptionCenter by remember { mutableStateOf(null) } + var systemOptionCenter by remember { mutableStateOf(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 BackHandler { onBack() } - Column( + Box( modifier = Modifier .fillMaxSize() + .onSizeChanged { rootSize = it } .background(backgroundColor) ) { - // Top Bar - Surface( - modifier = Modifier.fillMaxWidth(), - color = backgroundColor + Column( + modifier = Modifier.fillMaxSize() ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()) - .padding(horizontal = 4.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically + // Top Bar + Surface( + modifier = Modifier.fillMaxWidth(), + color = backgroundColor ) { - IconButton(onClick = onBack) { - Icon( - imageVector = TablerIcons.ChevronLeft, - contentDescription = "Back", - tint = textColor + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()) + .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, - fontWeight = FontWeight.SemiBold, - color = textColor, - modifier = Modifier.padding(start = 8.dp) + } + + // Content + Column( + modifier = Modifier + .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 - Column( - modifier = Modifier - .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 = { - if (themeMode != "light") { - themeMode = "light" - onThemeModeChange("light") + if (themeRevealActive) { + val snapshot = themeRevealSnapshot + if (snapshot != null) { + Canvas( + modifier = + Modifier.fillMaxSize() + .graphicsLayer( + compositingStrategy = CompositingStrategy.Offscreen + ) + ) { + val destinationSize = + IntSize( + width = size.width.toInt(), + height = size.height.toInt() + ) + 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 + ) + } } - }, - 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, secondaryTextColor: Color, showDivider: Boolean, - isDarkTheme: Boolean + isDarkTheme: Boolean, + onCenterInRootChanged: ((Offset) -> Unit)? = null ) { val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0) @@ -199,6 +360,15 @@ private fun TelegramThemeOption( modifier = Modifier .fillMaxWidth() .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), verticalAlignment = Alignment.CenterVertically ) { @@ -262,7 +432,9 @@ private fun ChatPreview(isDarkTheme: Boolean) { val otherBubbleColor = if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5) val myTextColor = Color.White // White text on blue bubble 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( modifier = Modifier @@ -289,7 +461,7 @@ private fun ChatPreview(isDarkTheme: Boolean) { isMe = false, bubbleColor = otherBubbleColor, textColor = otherTextColor, - timeColor = timeColor + timeColor = otherTimeColor ) } @@ -304,7 +476,8 @@ private fun ChatPreview(isDarkTheme: Boolean) { isMe = true, bubbleColor = myBubbleColor, textColor = myTextColor, - timeColor = timeColor + timeColor = myTimeColor, + checkmarkColor = myCheckColor ) } @@ -319,7 +492,7 @@ private fun ChatPreview(isDarkTheme: Boolean) { isMe = false, bubbleColor = otherBubbleColor, textColor = otherTextColor, - timeColor = timeColor + timeColor = otherTimeColor ) } } @@ -333,7 +506,8 @@ private fun MessageBubble( isMe: Boolean, bubbleColor: Color, textColor: Color, - timeColor: Color + timeColor: Color, + checkmarkColor: Color = Color(0xFF4FC3F7) ) { Surface( color = bubbleColor, @@ -372,7 +546,7 @@ private fun MessageBubble( Icon( painter = TelegramIcons.Done, contentDescription = null, - tint = Color(0xFF4FC3F7), // Blue checkmarks for read messages + tint = checkmarkColor, modifier = Modifier.size(14.dp) ) } diff --git a/app/src/main/res/drawable/updates_account.png b/app/src/main/res/drawable/updates_account.png new file mode 100644 index 0000000..98647a1 Binary files /dev/null and b/app/src/main/res/drawable/updates_account.png differ