From 6bb0a90ea0f55219e63c9d8de4d5c026e418d8e7 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Tue, 3 Feb 2026 21:50:44 +0500 Subject: [PATCH] fix: add caption support to image viewer with animated display --- .../com/rosetta/messenger/MainActivity.kt | 6 +- .../ui/chats/components/ImageEditorScreen.kt | 367 +++++++++--------- .../ui/chats/components/ImageViewerScreen.kt | 172 ++++---- .../ui/settings/OtherProfileScreen.kt | 43 +- .../messenger/ui/settings/ProfileScreen.kt | 92 ++--- 5 files changed, 351 insertions(+), 329 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 6679eb1..aa38cc2 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -225,7 +225,8 @@ class MainActivity : FragmentActivity() { isDarkTheme = isDarkTheme, onThemeToggle = { scope.launch { - preferencesManager.setDarkTheme(!isDarkTheme) + val newMode = if (isDarkTheme) "light" else "dark" + preferencesManager.setThemeMode(newMode) } }, onStartMessaging = { showOnboarding = false } @@ -307,7 +308,8 @@ class MainActivity : FragmentActivity() { themeMode = themeMode, onToggleTheme = { scope.launch { - preferencesManager.setDarkTheme(!isDarkTheme) + val newMode = if (isDarkTheme) "light" else "dark" + preferencesManager.setThemeMode(newMode) } }, onThemeModeChange = { mode -> 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 d279c33..abdb00f 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 @@ -74,6 +74,9 @@ import kotlinx.coroutines.withContext private const val TAG = "ImageEditorScreen" +/** Linear interpolation between two values */ +private fun lerp(start: Float, stop: Float, fraction: Float): Float = start + (stop - start) * fraction + /** Telegram-style easing */ private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f) @@ -159,28 +162,31 @@ fun ImageEditorScreen( } // 🎨 Черный статус бар и навигационный бар для редактора - val window = remember { (view.context as? Activity)?.window } - DisposableEffect(Unit) { - val originalStatusBarColor = window?.statusBarColor ?: 0 - val originalNavigationBarColor = window?.navigationBarColor ?: 0 + val activity = context as? Activity + val window = activity?.window + DisposableEffect(window) { + if (window == null) return@DisposableEffect onDispose { } + + val originalStatusBarColor = window.statusBarColor + val originalNavigationBarColor = window.navigationBarColor // 🔥 Сохраняем оригинальное состояние иконок статус бара - val insetsController = window?.let { WindowCompat.getInsetsController(it, view) } - val originalLightStatusBars = insetsController?.isAppearanceLightStatusBars ?: false - val originalLightNavigationBars = insetsController?.isAppearanceLightNavigationBars ?: false + val insetsController = WindowCompat.getInsetsController(window, view) + val originalLightStatusBars = insetsController.isAppearanceLightStatusBars + val originalLightNavigationBars = insetsController.isAppearanceLightNavigationBars // Устанавливаем черный цвет и светлые иконки - window?.statusBarColor = android.graphics.Color.BLACK - window?.navigationBarColor = android.graphics.Color.BLACK - insetsController?.isAppearanceLightStatusBars = false // Светлые иконки на черном фоне - insetsController?.isAppearanceLightNavigationBars = false + window.statusBarColor = android.graphics.Color.BLACK + window.navigationBarColor = android.graphics.Color.BLACK + insetsController.isAppearanceLightStatusBars = false // Светлые иконки на черном фоне + insetsController.isAppearanceLightNavigationBars = false onDispose { // Восстанавливаем оригинальные цвета и состояние иконок - window?.statusBarColor = originalStatusBarColor - window?.navigationBarColor = originalNavigationBarColor - insetsController?.isAppearanceLightStatusBars = originalLightStatusBars - insetsController?.isAppearanceLightNavigationBars = originalLightNavigationBars + window.statusBarColor = originalStatusBarColor + window.navigationBarColor = originalNavigationBarColor + insetsController.isAppearanceLightStatusBars = originalLightStatusBars + insetsController.isAppearanceLightNavigationBars = originalLightNavigationBars } } @@ -228,6 +234,7 @@ fun ImageEditorScreen( // Update coordinator through snapshotFlow LaunchedEffect(Unit) { snapshotFlow { with(density) { imeInsets.getBottom(density).toDp() } }.collect { currentImeHeight -> + val wasKeyboardVisible = isKeyboardVisible isKeyboardVisible = currentImeHeight > 50.dp coordinator.updateKeyboardHeight(currentImeHeight) if (currentImeHeight > 100.dp) { @@ -235,7 +242,10 @@ fun ImageEditorScreen( lastStableKeyboardHeight = currentImeHeight } // 📊 Log IME height changes - Log.d(TAG, "IME height: ${currentImeHeight.value}dp, isKeyboardVisible: $isKeyboardVisible, emojiHeight: ${coordinator.emojiHeight.value}dp, isEmojiBoxVisible: ${coordinator.isEmojiBoxVisible}") + Log.d(TAG, "⌨️ IME: height=${currentImeHeight.value}dp, wasVisible=$wasKeyboardVisible, isVisible=$isKeyboardVisible, emojiBoxVisible=${coordinator.isEmojiBoxVisible}") + if (wasKeyboardVisible != isKeyboardVisible) { + Log.d(TAG, "⌨️ KEYBOARD STATE CHANGED: $wasKeyboardVisible → $isKeyboardVisible") + } } } @@ -423,19 +433,26 @@ fun ImageEditorScreen( .statusBarsPadding() .padding(horizontal = 4.dp, vertical = 8.dp) ) { - // Close button (X) - сначала закрывает emoji/клавиатуру, потом экран + // Close button (X) - умное закрытие: клавиатура → emoji → экран IconButton( onClick = { - // Закрываем emoji picker если открыт - showEmojiPicker = false - - // Закрываем клавиатуру если открыта val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(view.windowToken, 0) - focusManager.clearFocus() - - // Всегда закрываем экран - animatedDismiss() + + when { + // 1. Если emoji picker открыт - закрываем только его + showEmojiPicker -> { + showEmojiPicker = false + } + // 2. Если клавиатура открыта - закрываем только её + isKeyboardVisible -> { + imm.hideSoftInputFromWindow(view.windowToken, 0) + focusManager.clearFocus() + } + // 3. Иначе - выходим с экрана + else -> { + animatedDismiss() + } + } }, modifier = Modifier.align(Alignment.CenterStart) ) { @@ -538,57 +555,38 @@ fun ImageEditorScreen( // 🔥 КЛЮЧЕВОЕ: imePadding применяется ТОЛЬКО когда emoji НЕ показан val shouldUseImePadding = !coordinator.isEmojiBoxVisible - + // 🔥 FIX: Сырое значение - emoji показан ИЛИ box виден val rawEmojiVisible = showEmojiPicker || coordinator.isEmojiBoxVisible - - // 🔥 FIX: Debounced флаг для emoji - с задержкой при закрытии чтобы не мигал - var stableShowEmojiPicker by remember { mutableStateOf(rawEmojiVisible) } - - LaunchedEffect(rawEmojiVisible) { - if (rawEmojiVisible) { - // Открытие emoji - мгновенно - stableShowEmojiPicker = true - } else { - // Закрытие emoji - с небольшой задержкой для плавности - delay(50) - stableShowEmojiPicker = false + + // ═══════════════════════════════════════════════════════════════ + // 🎬 УПРОЩЁННАЯ АНИМАЦИЯ ИНПУТА + // - Позиция: imePadding() + toolbarOffset (привязан к реальной высоте IME) + // - Визуал: progress анимирует цвет, углы, иконки + // ═══════════════════════════════════════════════════════════════ + + // Состояние: развёрнуто когда клавиатура ИЛИ emoji видны + val targetExpanded = isKeyboardVisible || rawEmojiVisible + + // Simple state-based padding (like ChatDetailInput) + val shouldAddNavBarPadding = !isKeyboardVisible && !coordinator.isEmojiBoxVisible + + // Progress для ВИЗУАЛЬНЫХ изменений (цвет, углы, иконки) + val visualProgress = remember { Animatable(0f) } + LaunchedEffect(targetExpanded) { + val targetValue = if (targetExpanded) 1f else 0f + if (visualProgress.value != targetValue) { + visualProgress.animateTo( + targetValue = targetValue, + animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing) + ) } } - - // 🔥 FIX: Debounced флаг для стилей инпута - предотвращает мигание при закрытии emoji - // Истинный когда клавиатура ИЛИ emoji открыты - val isInputExpanded = isKeyboardVisible || stableShowEmojiPicker - - // 🔥 FIX: Инициализируем с актуальным значением чтобы не было мигания при первом рендере - var isInputExpandedDebounced by remember { mutableStateOf(isInputExpanded) } - - LaunchedEffect(isInputExpanded) { - if (isInputExpanded) { - // Открытие - мгновенно - isInputExpandedDebounced = true - } else { - // Закрытие - с задержкой чтобы анимация emoji завершилась - delay(300) - isInputExpandedDebounced = false - } - } - - // Когда клавиатура/emoji закрыты - добавляем отступ снизу для toolbar (~100dp) - // 🔥 Анимируем отступ для плавного перехода - val bottomPaddingForCaption by animateDpAsState( - targetValue = if (!isInputExpandedDebounced) 100.dp else 0.dp, - animationSpec = tween(200, easing = FastOutSlowInEasing), - label = "bottomPadding" - ) - - // 📊 Log render state - Log.d(TAG, "RENDER: showEmoji=$showEmojiPicker, stableEmoji=$stableShowEmojiPicker, isKeyboard=$isKeyboardVisible, isEmojiBoxVisible=${coordinator.isEmojiBoxVisible}, useImePadding=$shouldUseImePadding, emojiHeight=${coordinator.emojiHeight.value}dp, bottomPadding=${bottomPaddingForCaption.value}dp") AnimatedVisibility( visible = showCaptionInput && currentTool == EditorTool.NONE, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), + enter = fadeIn(tween(200)) + expandVertically(expandFrom = Alignment.Bottom), + exit = fadeOut(tween(150)) + shrinkVertically(shrinkTowards = Alignment.Bottom), modifier = Modifier .fillMaxWidth() .align(Alignment.BottomCenter) @@ -596,19 +594,19 @@ fun ImageEditorScreen( Column( modifier = Modifier .fillMaxWidth() - // 🔥 Отступ для toolbar ТОЛЬКО когда клавиатура/emoji закрыты - .padding(bottom = bottomPaddingForCaption) - // 🔥 imePadding поднимает над клавиатурой (применяется только когда emoji не показан) + // Fixed padding: 0dp when keyboard/emoji shown, 100dp for toolbar when closed + .padding(bottom = if (isKeyboardVisible || rawEmojiVisible) 0.dp else 100.dp) + // imePadding for keyboard (only when emoji box not visible) .then(if (shouldUseImePadding) Modifier.imePadding() else Modifier) ) { TelegramCaptionBar( caption = caption, onCaptionChange = { caption = it }, isSaving = isSaving, - // 🔥 Используем debounced флаг чтобы стили не мигали при закрытии emoji - isKeyboardVisible = isInputExpandedDebounced, - // 🔥 FIX: Используем стабильный флаг чтобы иконка не мигала - showEmojiPicker = stableShowEmojiPicker, + // 🔥 Progress только для визуальных изменений (цвет, углы, иконки) + transitionProgress = visualProgress.value, + // Для иконки emoji/keyboard - используем актуальное состояние emoji + showEmojiPicker = rawEmojiVisible, onToggleEmojiPicker = { toggleEmojiPicker() }, onEditTextViewCreated = { editTextView = it }, onSend = { @@ -1011,47 +1009,37 @@ private fun TelegramRotateBar( /** * Telegram-style caption input bar with emoji support - * Меняет внешний вид в зависимости от состояния клавиатуры: - * - Клавиатура закрыта: стеклянный инпут с blur эффектом (не на всю ширину) - * - Клавиатура открыта: полный стиль (emoji + текст + галочка) + * Использует единый transitionProgress для синхронной анимации всех параметров: + * - 0f = компактный режим (клавиатура закрыта, "Add a caption" капсула) + * - 1f = развёрнутый режим (клавиатура/emoji открыты, полный инпут) */ @Composable private fun TelegramCaptionBar( caption: String, onCaptionChange: (String) -> Unit, isSaving: Boolean, - isKeyboardVisible: Boolean, + transitionProgress: Float, // 0f = compact, 1f = expanded showEmojiPicker: Boolean = false, onToggleEmojiPicker: () -> Unit = {}, onEditTextViewCreated: ((com.rosetta.messenger.ui.components.AppleEmojiEditTextView) -> Unit)? = null, onSend: () -> Unit ) { - // � FIX: Анимируем ВСЕ параметры стиля для плавного перехода без мигания - val cornerRadius by animateDpAsState( - targetValue = if (isKeyboardVisible) 0.dp else 24.dp, - animationSpec = tween(250, easing = FastOutSlowInEasing), - label = "corner" - ) + // 🎬 Все параметры вычисляются через единый progress для идеальной синхронизации + val progress = transitionProgress - val horizontalPadding by animateDpAsState( - targetValue = if (isKeyboardVisible) 0.dp else 12.dp, - animationSpec = tween(250, easing = FastOutSlowInEasing), - label = "hPadding" - ) - - // 🔥 FIX: Анимируем прозрачность и цвет фона для плавного перехода - val backgroundAlpha by animateFloatAsState( - targetValue = if (isKeyboardVisible) 0.75f else 0.85f, - animationSpec = tween(250, easing = FastOutSlowInEasing), - label = "bgAlpha" - ) - - // 🔥 FIX: Плавный переход цвета фона - val backgroundColor by animateColorAsState( - targetValue = if (isKeyboardVisible) Color.Black else Color(0xFF2C2C2E), - animationSpec = tween(250, easing = FastOutSlowInEasing), - label = "bgColor" - ) + // Corner radius: 24dp (compact) → 0dp (expanded) + val cornerRadius = lerp(24f, 0f, progress).dp + + // Horizontal padding: 12dp (compact) → 0dp (expanded) + val horizontalPadding = lerp(12f, 0f, progress).dp + + // Background alpha: 0.85f (compact) → 0.75f (expanded) + val backgroundAlpha = lerp(0.85f, 0.75f, progress) + + // Background color: interpolate between compact and expanded colors + val compactColor = Color(0xFF2C2C2E) + val expandedColor = Color.Black + val backgroundColor = androidx.compose.ui.graphics.lerp(compactColor, expandedColor, progress) Box( modifier = Modifier @@ -1067,47 +1055,43 @@ private fun TelegramCaptionBar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - // Левая иконка: камера когда клавиатура закрыта, emoji/keyboard когда открыта - // 🔥 FIX: Crossfade вместо AnimatedContent для более плавной анимации - Crossfade( - targetState = isKeyboardVisible to showEmojiPicker, - animationSpec = tween(200), - label = "left_icon" - ) { (keyboardOpen, emojiOpen) -> - if (keyboardOpen) { - // Клавиатура/emoji открыты - кликабельная иконка переключения - IconButton( - onClick = onToggleEmojiPicker, - modifier = Modifier.size(32.dp) - ) { - Icon( - if (emojiOpen) TablerIcons.Keyboard else TablerIcons.MoodSmile, - contentDescription = if (emojiOpen) "Keyboard" else "Emoji", - tint = Color.White.copy(alpha = 0.7f), - modifier = Modifier.size(26.dp) - ) - } - } else { - // Клавиатура закрыта - камера иконка (с одинаковым размером для избежания прыжка) - Box(modifier = Modifier.size(32.dp), contentAlignment = Alignment.Center) { - Icon( - TablerIcons.CameraPlus, - contentDescription = "Camera", - tint = Color.White.copy(alpha = 0.7f), - modifier = Modifier.size(26.dp) - ) - } + // 🎬 Левая иконка: плавная анимация через alpha + // Камера (compact) ↔ Emoji/Keyboard (expanded) + Box( + modifier = Modifier.size(32.dp), + contentAlignment = Alignment.Center + ) { + // Камера - видна когда progress близок к 0 + Icon( + TablerIcons.CameraPlus, + contentDescription = "Camera", + tint = Color.White.copy(alpha = 0.7f * (1f - progress)), + modifier = Modifier + .size(26.dp) + .graphicsLayer { alpha = 1f - progress } + ) + + // Emoji/Keyboard toggle - виден когда progress близок к 1 + IconButton( + onClick = onToggleEmojiPicker, + modifier = Modifier + .size(32.dp) + .graphicsLayer { alpha = progress }, + enabled = progress > 0.5f // Кликабелен только когда достаточно виден + ) { + Icon( + if (showEmojiPicker) TablerIcons.Keyboard else TablerIcons.MoodSmile, + contentDescription = if (showEmojiPicker) "Keyboard" else "Emoji", + tint = Color.White.copy(alpha = 0.7f * progress), + modifier = Modifier.size(26.dp) + ) } } // Caption text field - использует AppleEmojiTextField для правильной работы с фокусом - // 🔥 FIX: Анимируем высоту текстового поля - val textFieldMaxHeight by animateDpAsState( - targetValue = if (isKeyboardVisible) 100.dp else 24.dp, - animationSpec = tween(200, easing = FastOutSlowInEasing), - label = "textFieldHeight" - ) - + // 🎬 Высота через lerp для синхронной анимации + val textFieldMaxHeight = lerp(24f, 100f, progress).dp + Box( modifier = Modifier .weight(1f) @@ -1132,35 +1116,47 @@ private fun TelegramCaptionBar( ) } - // 🔥 FIX: Crossfade вместо AnimatedContent для кнопки отправки - Crossfade( - targetState = isKeyboardVisible, - animationSpec = tween(200), - label = "send_button" - ) { keyboardOpen -> - Box( - modifier = Modifier - .size(if (keyboardOpen) 32.dp else 36.dp) - .clip(CircleShape) - .background(PrimaryBlue) - .clickable(enabled = !isSaving) { onSend() }, - contentAlignment = Alignment.Center - ) { - if (isSaving) { - CircularProgressIndicator( - modifier = Modifier.size(if (keyboardOpen) 18.dp else 20.dp), - color = Color.White, - strokeWidth = 2.dp - ) - } else { - // Клавиатура открыта - галочка, закрыта - стрелка отправки + // 🎬 Кнопка отправки: плавная анимация размера и иконки через progress + // Размер: 36dp (compact) → 32dp (expanded) + val buttonSize = lerp(36f, 32f, progress).dp + val iconSize = lerp(22f, 20f, progress).dp + val progressIndicatorSize = lerp(20f, 18f, progress).dp + + Box( + modifier = Modifier + .size(buttonSize) + .clip(CircleShape) + .background(PrimaryBlue) + .clickable(enabled = !isSaving) { onSend() }, + contentAlignment = Alignment.Center + ) { + if (isSaving) { + CircularProgressIndicator( + modifier = Modifier.size(progressIndicatorSize), + color = Color.White, + strokeWidth = 2.dp + ) + } else { + // Плавное переключение иконок через alpha + Box(contentAlignment = Alignment.Center) { + // Стрелка отправки - видна когда compact (progress близок к 0) Icon( - if (keyboardOpen) TablerIcons.Check else TablerIcons.Send, + TablerIcons.Send, contentDescription = "Send", - tint = Color.White, + tint = Color.White.copy(alpha = 1f - progress), modifier = Modifier - .size(if (keyboardOpen) 20.dp else 22.dp) - .then(if (!keyboardOpen) Modifier.offset(x = 1.dp) else Modifier) + .size(iconSize) + .offset(x = 1.dp) + .graphicsLayer { alpha = 1f - progress } + ) + // Галочка - видна когда expanded (progress близок к 1) + Icon( + TablerIcons.Check, + contentDescription = "Done", + tint = Color.White.copy(alpha = progress), + modifier = Modifier + .size(iconSize) + .graphicsLayer { alpha = progress } ) } } @@ -1427,28 +1423,31 @@ fun MultiImageEditorScreen( val view = LocalView.current // 🎨 Черный статус бар и навигационный бар для редактора - val window = remember { (view.context as? Activity)?.window } - DisposableEffect(Unit) { - val originalStatusBarColor = window?.statusBarColor ?: 0 - val originalNavigationBarColor = window?.navigationBarColor ?: 0 + val activity = context as? Activity + val window = activity?.window + DisposableEffect(window) { + if (window == null) return@DisposableEffect onDispose { } + + val originalStatusBarColor = window.statusBarColor + val originalNavigationBarColor = window.navigationBarColor // 🔥 Сохраняем оригинальное состояние иконок статус бара - val insetsController = window?.let { WindowCompat.getInsetsController(it, view) } - val originalLightStatusBars = insetsController?.isAppearanceLightStatusBars ?: false - val originalLightNavigationBars = insetsController?.isAppearanceLightNavigationBars ?: false + val insetsController = WindowCompat.getInsetsController(window, view) + val originalLightStatusBars = insetsController.isAppearanceLightStatusBars + val originalLightNavigationBars = insetsController.isAppearanceLightNavigationBars // Устанавливаем черный цвет и светлые иконки - window?.statusBarColor = android.graphics.Color.BLACK - window?.navigationBarColor = android.graphics.Color.BLACK - insetsController?.isAppearanceLightStatusBars = false // Светлые иконки на черном фоне - insetsController?.isAppearanceLightNavigationBars = false + window.statusBarColor = android.graphics.Color.BLACK + window.navigationBarColor = android.graphics.Color.BLACK + insetsController.isAppearanceLightStatusBars = false // Светлые иконки на черном фоне + insetsController.isAppearanceLightNavigationBars = false onDispose { // Восстанавливаем оригинальные цвета и состояние иконок - window?.statusBarColor = originalStatusBarColor - window?.navigationBarColor = originalNavigationBarColor - insetsController?.isAppearanceLightStatusBars = originalLightStatusBars - insetsController?.isAppearanceLightNavigationBars = originalLightNavigationBars + window.statusBarColor = originalStatusBarColor + window.navigationBarColor = originalNavigationBarColor + insetsController.isAppearanceLightStatusBars = originalLightStatusBars + insetsController.isAppearanceLightNavigationBars = originalLightNavigationBars } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt index a6b79f8..2e72645 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt @@ -48,6 +48,7 @@ import com.rosetta.messenger.crypto.MessageCrypto import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.TransportManager import com.rosetta.messenger.ui.chats.models.ChatMessage +import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.utils.AttachmentFileManager import compose.icons.TablerIcons import compose.icons.tablericons.* @@ -92,7 +93,8 @@ data class ViewableImage( val senderName: String, val timestamp: Date, val width: Int = 0, - val height: Int = 0 + val height: Int = 0, + val caption: String = "" // Текст сообщения для отображения снизу ) /** @@ -384,57 +386,35 @@ fun ImageViewerScreen( } } } - + // ═══════════════════════════════════════════════════════════ - // 📍 PAGE INDICATOR - Telegram-style (200ms, 24dp slide снизу) + // 📝 CAPTION BAR - Telegram-style снизу с анимацией // ═══════════════════════════════════════════════════════════ - if (images.size > 1) { + val currentCaption = currentImage?.caption ?: "" + if (currentCaption.isNotEmpty()) { AnimatedVisibility( visible = showControls && animationState == 1 && !isClosing, enter = fadeIn(tween(200, easing = FastOutSlowInEasing)) + - slideInVertically(tween(200, easing = FastOutSlowInEasing)) { 96 }, // +24dp снизу + slideInVertically(tween(200, easing = FastOutSlowInEasing)) { 96 }, exit = fadeOut(tween(200, easing = FastOutSlowInEasing)) + slideOutVertically(tween(200, easing = FastOutSlowInEasing)) { 96 }, modifier = Modifier .align(Alignment.BottomCenter) - .padding(bottom = 32.dp) .navigationBarsPadding() ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color.Black.copy(alpha = 0.5f)) + .padding(horizontal = 16.dp, vertical = 16.dp) ) { - // Dots indicator - Row( - modifier = Modifier - .background( - Color.Black.copy(alpha = 0.5f), - RoundedCornerShape(16.dp) - ) - .padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - repeat(images.size.coerceAtMost(10)) { index -> - val isSelected = pagerState.currentPage == index - Box( - modifier = Modifier - .size(if (isSelected) 8.dp else 6.dp) - .clip(CircleShape) - .background( - if (isSelected) Color.White - else Color.White.copy(alpha = 0.4f) - ) - ) - } - } - // Показываем счетчик под точками если больше 10 фото - if (images.size > 10) { - Spacer(modifier = Modifier.height(6.dp)) - Text( - text = "${pagerState.currentPage + 1}/${images.size}", - color = Color.White.copy(alpha = 0.8f), - fontSize = 12.sp - ) - } + AppleEmojiText( + text = currentCaption, + color = Color.White, + fontSize = 15.sp, + maxLines = 4, + overflow = android.text.TextUtils.TruncateAt.END + ) } } } @@ -469,20 +449,20 @@ private fun ZoomableImage( var offsetX by remember { mutableFloatStateOf(0f) } var offsetY by remember { mutableFloatStateOf(0f) } - // Animated values for smooth transitions + // 🔥 Telegram-style smooth animations val animatedScale by animateFloatAsState( targetValue = scale, - animationSpec = spring(dampingRatio = 0.8f, stiffness = 300f), + animationSpec = spring(dampingRatio = 0.9f, stiffness = 400f), label = "scale" ) val animatedOffsetX by animateFloatAsState( targetValue = offsetX, - animationSpec = spring(dampingRatio = 0.8f, stiffness = 300f), + animationSpec = spring(dampingRatio = 0.9f, stiffness = 400f), label = "offsetX" ) val animatedOffsetY by animateFloatAsState( targetValue = offsetY, - animationSpec = spring(dampingRatio = 0.8f, stiffness = 300f), + animationSpec = spring(dampingRatio = 0.9f, stiffness = 400f), label = "offsetY" ) @@ -587,17 +567,29 @@ private fun ZoomableImage( onTap = { onTap() }, onDoubleTap = { tapOffset -> if (scale > 1.1f) { - // Zoom out + // Zoom out - плавно возвращаемся scale = 1f offsetX = 0f offsetY = 0f } else { - // Zoom in to tap point - scale = 2.5f + // 🔥 Telegram-style: zoom in к точке тапа + val targetScale = 2.5f val centerX = containerSize.width / 2f val centerY = containerSize.height / 2f - offsetX = (centerX - tapOffset.x) * 1.5f - offsetY = (centerY - tapOffset.y) * 1.5f + + // Вычисляем offset так, чтобы точка тапа осталась на месте + val tapX = tapOffset.x - centerX + val tapY = tapOffset.y - centerY + + scale = targetScale + offsetX = tapX - tapX * targetScale + offsetY = tapY - tapY * targetScale + + // Ограничиваем offset + val maxX = (containerSize.width * (targetScale - 1) / 2f) + val maxY = (containerSize.height * (targetScale - 1) / 2f) + offsetX = offsetX.coerceIn(-maxX, maxX) + offsetY = offsetY.coerceIn(-maxY, maxY) } } ) @@ -607,78 +599,94 @@ private fun ZoomableImage( var lastDragTime = 0L var lastDragY = 0f var currentVelocity = 0f - val touchSlopValue = 20f // Минимальное смещение для определения направления - + val touchSlopValue = 20f + awaitEachGesture { - // Wait for first down val down = awaitFirstDown(requireUnconsumed = false) lastDragTime = System.currentTimeMillis() lastDragY = down.position.y currentVelocity = 0f - + var zoom = 1f var pastTouchSlop = false var lockedToDismiss = false - + do { val event = awaitPointerEvent() val canceled = event.changes.any { it.isConsumed } - + if (!canceled) { val zoomChange = event.calculateZoom() val panChange = event.calculatePan() - + // 🔥 Telegram-style: получаем центр жеста для zoom к точке + val centroid = event.calculateCentroid(useCurrent = false) + if (!pastTouchSlop) { zoom *= zoomChange val centroidSize = event.calculateCentroidSize(useCurrent = false) val touchMoved = abs(panChange.x) > touchSlopValue || abs(panChange.y) > touchSlopValue val zoomMotion = abs(1 - zoom) * centroidSize > touchSlopValue - + if (touchMoved || zoomMotion) { pastTouchSlop = true - - // Decide: vertical dismiss or zoom/pan? - if (scale <= 1.05f && zoomChange == 1f && + + // Vertical dismiss only when not zoomed + if (scale <= 1.05f && zoomChange == 1f && abs(panChange.y) > abs(panChange.x) * 1.5f) { lockedToDismiss = true isVerticalDragging = true } } } - + if (pastTouchSlop) { if (lockedToDismiss) { - // Calculate velocity for smooth dismiss val currentTime = System.currentTimeMillis() val currentY = event.changes.firstOrNull()?.position?.y ?: lastDragY val timeDelta = (currentTime - lastDragTime).coerceAtLeast(1L) val positionDelta = currentY - lastDragY - - // Velocity in px/second currentVelocity = (positionDelta / timeDelta) * 1000f - lastDragTime = currentTime lastDragY = currentY - - // Vertical drag for dismiss with velocity + onVerticalDrag(panChange.y, currentVelocity) event.changes.forEach { it.consume() } } else { - // Zoom and pan + // 🔥 Telegram-style pinch zoom к центру жеста + val oldScale = scale val newScale = (scale * zoomChange).coerceIn(minScale, maxScale) - - // Calculate max offsets based on zoom + + // Центр контейнера + val centerX = containerSize.width / 2f + val centerY = containerSize.height / 2f + + // Вычисляем новый offset так, чтобы точка под пальцами оставалась на месте + var newOffsetX = offsetX + var newOffsetY = offsetY + + if (zoomChange != 1f && centroid != Offset.Unspecified) { + // Точка жеста относительно центра + val gestureX = centroid.x - centerX + val gestureY = centroid.y - centerY + + // Корректируем offset чтобы точка под пальцами не двигалась + val scaleRatio = newScale / oldScale + newOffsetX = gestureX - (gestureX - offsetX) * scaleRatio + newOffsetY = gestureY - (gestureY - offsetY) * scaleRatio + } + + // Добавляем pan + newOffsetX += panChange.x + newOffsetY += panChange.y + + // Ограничиваем offset val maxX = (containerSize.width * (newScale - 1) / 2f).coerceAtLeast(0f) val maxY = (containerSize.height * (newScale - 1) / 2f).coerceAtLeast(0f) - - val newOffsetX = (offsetX + panChange.x).coerceIn(-maxX, maxX) - val newOffsetY = (offsetY + panChange.y).coerceIn(-maxY, maxY) - + scale = newScale - offsetX = newOffsetX - offsetY = newOffsetY - - // Consume if zoomed to prevent pager swipe + offsetX = newOffsetX.coerceIn(-maxX, maxX) + offsetY = newOffsetY.coerceIn(-maxY, maxY) + if (scale > 1.05f) { event.changes.forEach { it.consume() } } @@ -686,13 +694,12 @@ private fun ZoomableImage( } } } while (event.changes.any { it.pressed }) - - // Pointer up - end drag + if (isVerticalDragging) { isVerticalDragging = false onDragEnd() } - + // Snap back if scale is close to 1 if (scale < 1.05f) { scale = 1f @@ -819,7 +826,8 @@ fun extractImagesFromMessages( senderName = if (message.isOutgoing) "You" else opponentName, timestamp = message.timestamp, width = attachment.width, - height = attachment.height + height = attachment.height, + caption = message.text ) } } 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 8bc6f10..20eec79 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 @@ -4,6 +4,7 @@ import android.app.Activity import android.util.Log import androidx.activity.compose.BackHandler import androidx.core.view.WindowCompat +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -69,6 +70,38 @@ private val COLLAPSED_HEADER_HEIGHT_OTHER = 64.dp private val AVATAR_SIZE_EXPANDED_OTHER = 120.dp private val AVATAR_SIZE_COLLAPSED_OTHER = 36.dp +/** + * Вычисляет средний цвет bitmap (для случаев когда Palette не может извлечь swatch) + * Используется для белых/чёрных/однотонных изображений + */ +private fun calculateAverageColor(bitmap: android.graphics.Bitmap): Color { + var redSum = 0L + var greenSum = 0L + var blueSum = 0L + + // Сэмплируем каждый 4-й пиксель для производительности + val step = 4 + var sampledCount = 0 + + for (y in 0 until bitmap.height step step) { + for (x in 0 until bitmap.width step step) { + val pixel = bitmap.getPixel(x, y) + redSum += android.graphics.Color.red(pixel) + greenSum += android.graphics.Color.green(pixel) + blueSum += android.graphics.Color.blue(pixel) + sampledCount++ + } + } + + if (sampledCount == 0) return Color.White + + return Color( + red = (redSum / sampledCount) / 255f, + green = (greenSum / sampledCount) / 255f, + blue = (blueSum / sampledCount) / 255f + ) +} + @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable fun OtherProfileScreen( @@ -474,12 +507,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 textColor by remember(hasAvatar, avatarColors) { - derivedStateOf { - if (isColorLight(avatarColors.backgroundColor)) Color.Black else Color.White - } - } + // ═══════════════════════════════════════════════════════════ + // 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой + // ═══════════════════════════════════════════════════════════ + val textColor = if (isDarkTheme) Color.White else Color.Black Box(modifier = Modifier.fillMaxWidth().height(headerHeight)) { // ═══════════════════════════════════════════════════════════ 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 5d53f22..43ec4d6 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 @@ -112,6 +112,41 @@ fun isColorLight(color: Color): Boolean { return luminance > 0.5f } +/** + * Вычисляет средний цвет bitmap (для случаев когда Palette не может извлечь swatch) + * Используется для белых/чёрных/однотонных изображений + */ +private fun calculateAverageColor(bitmap: android.graphics.Bitmap): Color { + var redSum = 0L + var greenSum = 0L + var blueSum = 0L + val width = bitmap.width + val height = bitmap.height + val pixelCount = width * height + + // Сэмплируем каждый 4-й пиксель для производительности + val step = 4 + var sampledCount = 0 + + for (y in 0 until height step step) { + for (x in 0 until width step step) { + val pixel = bitmap.getPixel(x, y) + redSum += android.graphics.Color.red(pixel) + greenSum += android.graphics.Color.green(pixel) + blueSum += android.graphics.Color.blue(pixel) + sampledCount++ + } + } + + if (sampledCount == 0) return Color.White + + return Color( + red = (redSum / sampledCount) / 255f, + green = (greenSum / sampledCount) / 255f, + blue = (blueSum / sampledCount) / 255f + ) +} + fun getAvatarColor(name: String, isDarkTheme: Boolean): AvatarColors { val cacheKey = "${name}_${if (isDarkTheme) "dark" else "light"}" return avatarColorCache.getOrPut(cacheKey) { @@ -800,62 +835,9 @@ private fun CollapsingProfileHeader( val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() // ═══════════════════════════════════════════════════════════ - // 🎨 DOMINANT COLOR - извлекаем из аватарки для контраста текста + // 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой // ═══════════════════════════════════════════════════════════ - val avatars by - avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState() - ?: remember { mutableStateOf(emptyList()) } - - var avatarBitmap by remember(avatars) { mutableStateOf(null) } - var dominantColor by remember { mutableStateOf(null) } - - - LaunchedEffect(avatars, publicKey) { - if (avatars.isNotEmpty()) { - val loadedBitmap = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - AvatarFileManager.base64ToBitmap(avatars.first().base64Data) - } - avatarBitmap = loadedBitmap - // Извлекаем доминантный цвет из нижней части аватарки (где будет текст) - loadedBitmap?.let { bitmap -> - try { - // Берем нижнюю треть изображения для более точного определения - val bottomThird = - android.graphics.Bitmap.createBitmap( - bitmap, - 0, - (bitmap.height * 2 / 3).coerceAtLeast(1), - bitmap.width, - (bitmap.height / 3).coerceAtLeast(1) - ) - val palette = AndroidPalette.from(bottomThird).generate() - // Используем доминантный цвет или muted swatch - val swatch = palette.dominantSwatch ?: palette.mutedSwatch - if (swatch != null) { - val extractedColor = Color(swatch.rgb) - dominantColor = extractedColor - } else { - } - } catch (e: Exception) { - } - } - } else { - avatarBitmap = null - dominantColor = null - } - } - - // Определяем цвет текста на основе фона - derivedStateOf для реактивности - val textColor by remember { - derivedStateOf { - if (hasAvatar && dominantColor != null) { - val isLight = isColorLight(dominantColor!!) - if (isLight) Color.Black else Color.White - } else { - if (isColorLight(avatarColors.backgroundColor)) Color.Black else Color.White - } - } - } + val textColor = if (isDarkTheme) Color.White else Color.Black // ═══════════════════════════════════════════════════════════ // 📐 HEADER HEIGHT - ФИКСИРОВАННАЯ! Не меняется при overscroll