fix: add caption support to image viewer with animated display

This commit is contained in:
k1ngsterr1
2026-02-03 21:50:44 +05:00
parent c2283fe0e5
commit 6bb0a90ea0
5 changed files with 351 additions and 329 deletions

View File

@@ -225,7 +225,8 @@ class MainActivity : FragmentActivity() {
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onThemeToggle = { onThemeToggle = {
scope.launch { scope.launch {
preferencesManager.setDarkTheme(!isDarkTheme) val newMode = if (isDarkTheme) "light" else "dark"
preferencesManager.setThemeMode(newMode)
} }
}, },
onStartMessaging = { showOnboarding = false } onStartMessaging = { showOnboarding = false }
@@ -307,7 +308,8 @@ class MainActivity : FragmentActivity() {
themeMode = themeMode, themeMode = themeMode,
onToggleTheme = { onToggleTheme = {
scope.launch { scope.launch {
preferencesManager.setDarkTheme(!isDarkTheme) val newMode = if (isDarkTheme) "light" else "dark"
preferencesManager.setThemeMode(newMode)
} }
}, },
onThemeModeChange = { mode -> onThemeModeChange = { mode ->

View File

@@ -74,6 +74,9 @@ import kotlinx.coroutines.withContext
private const val TAG = "ImageEditorScreen" 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 */ /** Telegram-style easing */
private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f) 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 } val activity = context as? Activity
DisposableEffect(Unit) { val window = activity?.window
val originalStatusBarColor = window?.statusBarColor ?: 0 DisposableEffect(window) {
val originalNavigationBarColor = window?.navigationBarColor ?: 0 if (window == null) return@DisposableEffect onDispose { }
val originalStatusBarColor = window.statusBarColor
val originalNavigationBarColor = window.navigationBarColor
// 🔥 Сохраняем оригинальное состояние иконок статус бара // 🔥 Сохраняем оригинальное состояние иконок статус бара
val insetsController = window?.let { WindowCompat.getInsetsController(it, view) } val insetsController = WindowCompat.getInsetsController(window, view)
val originalLightStatusBars = insetsController?.isAppearanceLightStatusBars ?: false val originalLightStatusBars = insetsController.isAppearanceLightStatusBars
val originalLightNavigationBars = insetsController?.isAppearanceLightNavigationBars ?: false val originalLightNavigationBars = insetsController.isAppearanceLightNavigationBars
// Устанавливаем черный цвет и светлые иконки // Устанавливаем черный цвет и светлые иконки
window?.statusBarColor = android.graphics.Color.BLACK window.statusBarColor = android.graphics.Color.BLACK
window?.navigationBarColor = android.graphics.Color.BLACK window.navigationBarColor = android.graphics.Color.BLACK
insetsController?.isAppearanceLightStatusBars = false // Светлые иконки на черном фоне insetsController.isAppearanceLightStatusBars = false // Светлые иконки на черном фоне
insetsController?.isAppearanceLightNavigationBars = false insetsController.isAppearanceLightNavigationBars = false
onDispose { onDispose {
// Восстанавливаем оригинальные цвета и состояние иконок // Восстанавливаем оригинальные цвета и состояние иконок
window?.statusBarColor = originalStatusBarColor window.statusBarColor = originalStatusBarColor
window?.navigationBarColor = originalNavigationBarColor window.navigationBarColor = originalNavigationBarColor
insetsController?.isAppearanceLightStatusBars = originalLightStatusBars insetsController.isAppearanceLightStatusBars = originalLightStatusBars
insetsController?.isAppearanceLightNavigationBars = originalLightNavigationBars insetsController.isAppearanceLightNavigationBars = originalLightNavigationBars
} }
} }
@@ -228,6 +234,7 @@ fun ImageEditorScreen(
// Update coordinator through snapshotFlow // Update coordinator through snapshotFlow
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
snapshotFlow { with(density) { imeInsets.getBottom(density).toDp() } }.collect { currentImeHeight -> snapshotFlow { with(density) { imeInsets.getBottom(density).toDp() } }.collect { currentImeHeight ->
val wasKeyboardVisible = isKeyboardVisible
isKeyboardVisible = currentImeHeight > 50.dp isKeyboardVisible = currentImeHeight > 50.dp
coordinator.updateKeyboardHeight(currentImeHeight) coordinator.updateKeyboardHeight(currentImeHeight)
if (currentImeHeight > 100.dp) { if (currentImeHeight > 100.dp) {
@@ -235,7 +242,10 @@ fun ImageEditorScreen(
lastStableKeyboardHeight = currentImeHeight lastStableKeyboardHeight = currentImeHeight
} }
// 📊 Log IME height changes // 📊 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() .statusBarsPadding()
.padding(horizontal = 4.dp, vertical = 8.dp) .padding(horizontal = 4.dp, vertical = 8.dp)
) { ) {
// Close button (X) - сначала закрывает emoji/клавиатуру, потом экран // Close button (X) - умное закрытие: клавиатура → emoji → экран
IconButton( IconButton(
onClick = { onClick = {
// Закрываем emoji picker если открыт
showEmojiPicker = false
// Закрываем клавиатуру если открыта
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus()
// Всегда закрываем экран when {
animatedDismiss() // 1. Если emoji picker открыт - закрываем только его
showEmojiPicker -> {
showEmojiPicker = false
}
// 2. Если клавиатура открыта - закрываем только её
isKeyboardVisible -> {
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus()
}
// 3. Иначе - выходим с экрана
else -> {
animatedDismiss()
}
}
}, },
modifier = Modifier.align(Alignment.CenterStart) modifier = Modifier.align(Alignment.CenterStart)
) { ) {
@@ -542,53 +559,34 @@ fun ImageEditorScreen(
// 🔥 FIX: Сырое значение - emoji показан ИЛИ box виден // 🔥 FIX: Сырое значение - emoji показан ИЛИ box виден
val rawEmojiVisible = showEmojiPicker || coordinator.isEmojiBoxVisible val rawEmojiVisible = showEmojiPicker || coordinator.isEmojiBoxVisible
// 🔥 FIX: Debounced флаг для emoji - с задержкой при закрытии чтобы не мигал // ═══════════════════════════════════════════════════════════════
var stableShowEmojiPicker by remember { mutableStateOf(rawEmojiVisible) } // 🎬 УПРОЩЁННАЯ АНИМАЦИЯ ИНПУТА
// - Позиция: imePadding() + toolbarOffset (привязан к реальной высоте IME)
// - Визуал: progress анимирует цвет, углы, иконки
// ═══════════════════════════════════════════════════════════════
LaunchedEffect(rawEmojiVisible) { // Состояние: развёрнуто когда клавиатура ИЛИ emoji видны
if (rawEmojiVisible) { val targetExpanded = isKeyboardVisible || rawEmojiVisible
// Открытие emoji - мгновенно
stableShowEmojiPicker = true // Simple state-based padding (like ChatDetailInput)
} else { val shouldAddNavBarPadding = !isKeyboardVisible && !coordinator.isEmojiBoxVisible
// Закрытие emoji - с небольшой задержкой для плавности
delay(50) // Progress для ВИЗУАЛЬНЫХ изменений (цвет, углы, иконки)
stableShowEmojiPicker = false 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( AnimatedVisibility(
visible = showCaptionInput && currentTool == EditorTool.NONE, visible = showCaptionInput && currentTool == EditorTool.NONE,
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), enter = fadeIn(tween(200)) + expandVertically(expandFrom = Alignment.Bottom),
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), exit = fadeOut(tween(150)) + shrinkVertically(shrinkTowards = Alignment.Bottom),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
@@ -596,19 +594,19 @@ fun ImageEditorScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
// 🔥 Отступ для toolbar ТОЛЬКО когда клавиатура/emoji закрыты // Fixed padding: 0dp when keyboard/emoji shown, 100dp for toolbar when closed
.padding(bottom = bottomPaddingForCaption) .padding(bottom = if (isKeyboardVisible || rawEmojiVisible) 0.dp else 100.dp)
// 🔥 imePadding поднимает над клавиатурой (применяется только когда emoji не показан) // imePadding for keyboard (only when emoji box not visible)
.then(if (shouldUseImePadding) Modifier.imePadding() else Modifier) .then(if (shouldUseImePadding) Modifier.imePadding() else Modifier)
) { ) {
TelegramCaptionBar( TelegramCaptionBar(
caption = caption, caption = caption,
onCaptionChange = { caption = it }, onCaptionChange = { caption = it },
isSaving = isSaving, isSaving = isSaving,
// 🔥 Используем debounced флаг чтобы стили не мигали при закрытии emoji // 🔥 Progress только для визуальных изменений (цвет, углы, иконки)
isKeyboardVisible = isInputExpandedDebounced, transitionProgress = visualProgress.value,
// 🔥 FIX: Используем стабильный флаг чтобы иконка не мигала // Для иконки emoji/keyboard - используем актуальное состояние emoji
showEmojiPicker = stableShowEmojiPicker, showEmojiPicker = rawEmojiVisible,
onToggleEmojiPicker = { toggleEmojiPicker() }, onToggleEmojiPicker = { toggleEmojiPicker() },
onEditTextViewCreated = { editTextView = it }, onEditTextViewCreated = { editTextView = it },
onSend = { onSend = {
@@ -1011,47 +1009,37 @@ private fun TelegramRotateBar(
/** /**
* Telegram-style caption input bar with emoji support * Telegram-style caption input bar with emoji support
* Меняет внешний вид в зависимости от состояния клавиатуры: * Использует единый transitionProgress для синхронной анимации всех параметров:
* - Клавиатура закрыта: стеклянный инпут с blur эффектом (не на всю ширину) * - 0f = компактный режим (клавиатура закрыта, "Add a caption" капсула)
* - Клавиатура открыта: полный стиль (emoji + текст + галочка) * - 1f = развёрнутый режим (клавиатура/emoji открыты, полный инпут)
*/ */
@Composable @Composable
private fun TelegramCaptionBar( private fun TelegramCaptionBar(
caption: String, caption: String,
onCaptionChange: (String) -> Unit, onCaptionChange: (String) -> Unit,
isSaving: Boolean, isSaving: Boolean,
isKeyboardVisible: Boolean, transitionProgress: Float, // 0f = compact, 1f = expanded
showEmojiPicker: Boolean = false, showEmojiPicker: Boolean = false,
onToggleEmojiPicker: () -> Unit = {}, onToggleEmojiPicker: () -> Unit = {},
onEditTextViewCreated: ((com.rosetta.messenger.ui.components.AppleEmojiEditTextView) -> Unit)? = null, onEditTextViewCreated: ((com.rosetta.messenger.ui.components.AppleEmojiEditTextView) -> Unit)? = null,
onSend: () -> Unit onSend: () -> Unit
) { ) {
// <EFBFBD> FIX: Анимируем ВСЕ параметры стиля для плавного перехода без мигания // 🎬 Все параметры вычисляются через единый progress для идеальной синхронизации
val cornerRadius by animateDpAsState( val progress = transitionProgress
targetValue = if (isKeyboardVisible) 0.dp else 24.dp,
animationSpec = tween(250, easing = FastOutSlowInEasing),
label = "corner"
)
val horizontalPadding by animateDpAsState( // Corner radius: 24dp (compact) → 0dp (expanded)
targetValue = if (isKeyboardVisible) 0.dp else 12.dp, val cornerRadius = lerp(24f, 0f, progress).dp
animationSpec = tween(250, easing = FastOutSlowInEasing),
label = "hPadding"
)
// 🔥 FIX: Анимируем прозрачность и цвет фона для плавного перехода // Horizontal padding: 12dp (compact) → 0dp (expanded)
val backgroundAlpha by animateFloatAsState( val horizontalPadding = lerp(12f, 0f, progress).dp
targetValue = if (isKeyboardVisible) 0.75f else 0.85f,
animationSpec = tween(250, easing = FastOutSlowInEasing),
label = "bgAlpha"
)
// 🔥 FIX: Плавный переход цвета фона // Background alpha: 0.85f (compact) → 0.75f (expanded)
val backgroundColor by animateColorAsState( val backgroundAlpha = lerp(0.85f, 0.75f, progress)
targetValue = if (isKeyboardVisible) Color.Black else Color(0xFF2C2C2E),
animationSpec = tween(250, easing = FastOutSlowInEasing), // Background color: interpolate between compact and expanded colors
label = "bgColor" val compactColor = Color(0xFF2C2C2E)
) val expandedColor = Color.Black
val backgroundColor = androidx.compose.ui.graphics.lerp(compactColor, expandedColor, progress)
Box( Box(
modifier = Modifier modifier = Modifier
@@ -1067,46 +1055,42 @@ private fun TelegramCaptionBar(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp) horizontalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
// Левая иконка: камера когда клавиатура закрыта, emoji/keyboard когда открыта // 🎬 Левая иконка: плавная анимация через alpha
// 🔥 FIX: Crossfade вместо AnimatedContent для более плавной анимации // Камера (compact) ↔ Emoji/Keyboard (expanded)
Crossfade( Box(
targetState = isKeyboardVisible to showEmojiPicker, modifier = Modifier.size(32.dp),
animationSpec = tween(200), contentAlignment = Alignment.Center
label = "left_icon" ) {
) { (keyboardOpen, emojiOpen) -> // Камера - видна когда progress близок к 0
if (keyboardOpen) { Icon(
// Клавиатура/emoji открыты - кликабельная иконка переключения TablerIcons.CameraPlus,
IconButton( contentDescription = "Camera",
onClick = onToggleEmojiPicker, tint = Color.White.copy(alpha = 0.7f * (1f - progress)),
modifier = Modifier.size(32.dp) modifier = Modifier
) { .size(26.dp)
Icon( .graphicsLayer { alpha = 1f - progress }
if (emojiOpen) TablerIcons.Keyboard else TablerIcons.MoodSmile, )
contentDescription = if (emojiOpen) "Keyboard" else "Emoji",
tint = Color.White.copy(alpha = 0.7f), // Emoji/Keyboard toggle - виден когда progress близок к 1
modifier = Modifier.size(26.dp) IconButton(
) onClick = onToggleEmojiPicker,
} modifier = Modifier
} else { .size(32.dp)
// Клавиатура закрыта - камера иконка (с одинаковым размером для избежания прыжка) .graphicsLayer { alpha = progress },
Box(modifier = Modifier.size(32.dp), contentAlignment = Alignment.Center) { enabled = progress > 0.5f // Кликабелен только когда достаточно виден
Icon( ) {
TablerIcons.CameraPlus, Icon(
contentDescription = "Camera", if (showEmojiPicker) TablerIcons.Keyboard else TablerIcons.MoodSmile,
tint = Color.White.copy(alpha = 0.7f), contentDescription = if (showEmojiPicker) "Keyboard" else "Emoji",
modifier = Modifier.size(26.dp) tint = Color.White.copy(alpha = 0.7f * progress),
) modifier = Modifier.size(26.dp)
} )
} }
} }
// Caption text field - использует AppleEmojiTextField для правильной работы с фокусом // Caption text field - использует AppleEmojiTextField для правильной работы с фокусом
// 🔥 FIX: Анимируем высоту текстового поля // 🎬 Высота через lerp для синхронной анимации
val textFieldMaxHeight by animateDpAsState( val textFieldMaxHeight = lerp(24f, 100f, progress).dp
targetValue = if (isKeyboardVisible) 100.dp else 24.dp,
animationSpec = tween(200, easing = FastOutSlowInEasing),
label = "textFieldHeight"
)
Box( Box(
modifier = Modifier modifier = Modifier
@@ -1132,35 +1116,47 @@ private fun TelegramCaptionBar(
) )
} }
// 🔥 FIX: Crossfade вместо AnimatedContent для кнопки отправки // 🎬 Кнопка отправки: плавная анимация размера и иконки через progress
Crossfade( // Размер: 36dp (compact) → 32dp (expanded)
targetState = isKeyboardVisible, val buttonSize = lerp(36f, 32f, progress).dp
animationSpec = tween(200), val iconSize = lerp(22f, 20f, progress).dp
label = "send_button" val progressIndicatorSize = lerp(20f, 18f, progress).dp
) { keyboardOpen ->
Box( Box(
modifier = Modifier modifier = Modifier
.size(if (keyboardOpen) 32.dp else 36.dp) .size(buttonSize)
.clip(CircleShape) .clip(CircleShape)
.background(PrimaryBlue) .background(PrimaryBlue)
.clickable(enabled = !isSaving) { onSend() }, .clickable(enabled = !isSaving) { onSend() },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (isSaving) { if (isSaving) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(if (keyboardOpen) 18.dp else 20.dp), modifier = Modifier.size(progressIndicatorSize),
color = Color.White, color = Color.White,
strokeWidth = 2.dp strokeWidth = 2.dp
) )
} else { } else {
// Клавиатура открыта - галочка, закрыта - стрелка отправки // Плавное переключение иконок через alpha
Box(contentAlignment = Alignment.Center) {
// Стрелка отправки - видна когда compact (progress близок к 0)
Icon( Icon(
if (keyboardOpen) TablerIcons.Check else TablerIcons.Send, TablerIcons.Send,
contentDescription = "Send", contentDescription = "Send",
tint = Color.White, tint = Color.White.copy(alpha = 1f - progress),
modifier = Modifier modifier = Modifier
.size(if (keyboardOpen) 20.dp else 22.dp) .size(iconSize)
.then(if (!keyboardOpen) Modifier.offset(x = 1.dp) else Modifier) .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 view = LocalView.current
// 🎨 Черный статус бар и навигационный бар для редактора // 🎨 Черный статус бар и навигационный бар для редактора
val window = remember { (view.context as? Activity)?.window } val activity = context as? Activity
DisposableEffect(Unit) { val window = activity?.window
val originalStatusBarColor = window?.statusBarColor ?: 0 DisposableEffect(window) {
val originalNavigationBarColor = window?.navigationBarColor ?: 0 if (window == null) return@DisposableEffect onDispose { }
val originalStatusBarColor = window.statusBarColor
val originalNavigationBarColor = window.navigationBarColor
// 🔥 Сохраняем оригинальное состояние иконок статус бара // 🔥 Сохраняем оригинальное состояние иконок статус бара
val insetsController = window?.let { WindowCompat.getInsetsController(it, view) } val insetsController = WindowCompat.getInsetsController(window, view)
val originalLightStatusBars = insetsController?.isAppearanceLightStatusBars ?: false val originalLightStatusBars = insetsController.isAppearanceLightStatusBars
val originalLightNavigationBars = insetsController?.isAppearanceLightNavigationBars ?: false val originalLightNavigationBars = insetsController.isAppearanceLightNavigationBars
// Устанавливаем черный цвет и светлые иконки // Устанавливаем черный цвет и светлые иконки
window?.statusBarColor = android.graphics.Color.BLACK window.statusBarColor = android.graphics.Color.BLACK
window?.navigationBarColor = android.graphics.Color.BLACK window.navigationBarColor = android.graphics.Color.BLACK
insetsController?.isAppearanceLightStatusBars = false // Светлые иконки на черном фоне insetsController.isAppearanceLightStatusBars = false // Светлые иконки на черном фоне
insetsController?.isAppearanceLightNavigationBars = false insetsController.isAppearanceLightNavigationBars = false
onDispose { onDispose {
// Восстанавливаем оригинальные цвета и состояние иконок // Восстанавливаем оригинальные цвета и состояние иконок
window?.statusBarColor = originalStatusBarColor window.statusBarColor = originalStatusBarColor
window?.navigationBarColor = originalNavigationBarColor window.navigationBarColor = originalNavigationBarColor
insetsController?.isAppearanceLightStatusBars = originalLightStatusBars insetsController.isAppearanceLightStatusBars = originalLightStatusBars
insetsController?.isAppearanceLightNavigationBars = originalLightNavigationBars insetsController.isAppearanceLightNavigationBars = originalLightNavigationBars
} }
} }

View File

@@ -48,6 +48,7 @@ import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.TransportManager import com.rosetta.messenger.network.TransportManager
import com.rosetta.messenger.ui.chats.models.ChatMessage import com.rosetta.messenger.ui.chats.models.ChatMessage
import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.utils.AttachmentFileManager import com.rosetta.messenger.utils.AttachmentFileManager
import compose.icons.TablerIcons import compose.icons.TablerIcons
import compose.icons.tablericons.* import compose.icons.tablericons.*
@@ -92,7 +93,8 @@ data class ViewableImage(
val senderName: String, val senderName: String,
val timestamp: Date, val timestamp: Date,
val width: Int = 0, val width: Int = 0,
val height: Int = 0 val height: Int = 0,
val caption: String = "" // Текст сообщения для отображения снизу
) )
/** /**
@@ -386,55 +388,33 @@ 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( AnimatedVisibility(
visible = showControls && animationState == 1 && !isClosing, visible = showControls && animationState == 1 && !isClosing,
enter = fadeIn(tween(200, easing = FastOutSlowInEasing)) + 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)) + exit = fadeOut(tween(200, easing = FastOutSlowInEasing)) +
slideOutVertically(tween(200, easing = FastOutSlowInEasing)) { 96 }, slideOutVertically(tween(200, easing = FastOutSlowInEasing)) { 96 },
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(bottom = 32.dp)
.navigationBarsPadding() .navigationBarsPadding()
) { ) {
Column( Box(
horizontalAlignment = Alignment.CenterHorizontally modifier = Modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.5f))
.padding(horizontal = 16.dp, vertical = 16.dp)
) { ) {
// Dots indicator AppleEmojiText(
Row( text = currentCaption,
modifier = Modifier color = Color.White,
.background( fontSize = 15.sp,
Color.Black.copy(alpha = 0.5f), maxLines = 4,
RoundedCornerShape(16.dp) overflow = android.text.TextUtils.TruncateAt.END
) )
.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
)
}
} }
} }
} }
@@ -469,20 +449,20 @@ private fun ZoomableImage(
var offsetX by remember { mutableFloatStateOf(0f) } var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) } var offsetY by remember { mutableFloatStateOf(0f) }
// Animated values for smooth transitions // 🔥 Telegram-style smooth animations
val animatedScale by animateFloatAsState( val animatedScale by animateFloatAsState(
targetValue = scale, targetValue = scale,
animationSpec = spring(dampingRatio = 0.8f, stiffness = 300f), animationSpec = spring(dampingRatio = 0.9f, stiffness = 400f),
label = "scale" label = "scale"
) )
val animatedOffsetX by animateFloatAsState( val animatedOffsetX by animateFloatAsState(
targetValue = offsetX, targetValue = offsetX,
animationSpec = spring(dampingRatio = 0.8f, stiffness = 300f), animationSpec = spring(dampingRatio = 0.9f, stiffness = 400f),
label = "offsetX" label = "offsetX"
) )
val animatedOffsetY by animateFloatAsState( val animatedOffsetY by animateFloatAsState(
targetValue = offsetY, targetValue = offsetY,
animationSpec = spring(dampingRatio = 0.8f, stiffness = 300f), animationSpec = spring(dampingRatio = 0.9f, stiffness = 400f),
label = "offsetY" label = "offsetY"
) )
@@ -587,17 +567,29 @@ private fun ZoomableImage(
onTap = { onTap() }, onTap = { onTap() },
onDoubleTap = { tapOffset -> onDoubleTap = { tapOffset ->
if (scale > 1.1f) { if (scale > 1.1f) {
// Zoom out // Zoom out - плавно возвращаемся
scale = 1f scale = 1f
offsetX = 0f offsetX = 0f
offsetY = 0f offsetY = 0f
} else { } else {
// Zoom in to tap point // 🔥 Telegram-style: zoom in к точке тапа
scale = 2.5f val targetScale = 2.5f
val centerX = containerSize.width / 2f val centerX = containerSize.width / 2f
val centerY = containerSize.height / 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,10 +599,9 @@ private fun ZoomableImage(
var lastDragTime = 0L var lastDragTime = 0L
var lastDragY = 0f var lastDragY = 0f
var currentVelocity = 0f var currentVelocity = 0f
val touchSlopValue = 20f // Минимальное смещение для определения направления val touchSlopValue = 20f
awaitEachGesture { awaitEachGesture {
// Wait for first down
val down = awaitFirstDown(requireUnconsumed = false) val down = awaitFirstDown(requireUnconsumed = false)
lastDragTime = System.currentTimeMillis() lastDragTime = System.currentTimeMillis()
lastDragY = down.position.y lastDragY = down.position.y
@@ -627,6 +618,8 @@ private fun ZoomableImage(
if (!canceled) { if (!canceled) {
val zoomChange = event.calculateZoom() val zoomChange = event.calculateZoom()
val panChange = event.calculatePan() val panChange = event.calculatePan()
// 🔥 Telegram-style: получаем центр жеста для zoom к точке
val centroid = event.calculateCentroid(useCurrent = false)
if (!pastTouchSlop) { if (!pastTouchSlop) {
zoom *= zoomChange zoom *= zoomChange
@@ -637,7 +630,7 @@ private fun ZoomableImage(
if (touchMoved || zoomMotion) { if (touchMoved || zoomMotion) {
pastTouchSlop = true pastTouchSlop = true
// Decide: vertical dismiss or zoom/pan? // Vertical dismiss only when not zoomed
if (scale <= 1.05f && zoomChange == 1f && if (scale <= 1.05f && zoomChange == 1f &&
abs(panChange.y) > abs(panChange.x) * 1.5f) { abs(panChange.y) > abs(panChange.x) * 1.5f) {
lockedToDismiss = true lockedToDismiss = true
@@ -648,37 +641,52 @@ private fun ZoomableImage(
if (pastTouchSlop) { if (pastTouchSlop) {
if (lockedToDismiss) { if (lockedToDismiss) {
// Calculate velocity for smooth dismiss
val currentTime = System.currentTimeMillis() val currentTime = System.currentTimeMillis()
val currentY = event.changes.firstOrNull()?.position?.y ?: lastDragY val currentY = event.changes.firstOrNull()?.position?.y ?: lastDragY
val timeDelta = (currentTime - lastDragTime).coerceAtLeast(1L) val timeDelta = (currentTime - lastDragTime).coerceAtLeast(1L)
val positionDelta = currentY - lastDragY val positionDelta = currentY - lastDragY
// Velocity in px/second
currentVelocity = (positionDelta / timeDelta) * 1000f currentVelocity = (positionDelta / timeDelta) * 1000f
lastDragTime = currentTime lastDragTime = currentTime
lastDragY = currentY lastDragY = currentY
// Vertical drag for dismiss with velocity
onVerticalDrag(panChange.y, currentVelocity) onVerticalDrag(panChange.y, currentVelocity)
event.changes.forEach { it.consume() } event.changes.forEach { it.consume() }
} else { } else {
// Zoom and pan // 🔥 Telegram-style pinch zoom к центру жеста
val oldScale = scale
val newScale = (scale * zoomChange).coerceIn(minScale, maxScale) 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 maxX = (containerSize.width * (newScale - 1) / 2f).coerceAtLeast(0f)
val maxY = (containerSize.height * (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 scale = newScale
offsetX = newOffsetX offsetX = newOffsetX.coerceIn(-maxX, maxX)
offsetY = newOffsetY offsetY = newOffsetY.coerceIn(-maxY, maxY)
// Consume if zoomed to prevent pager swipe
if (scale > 1.05f) { if (scale > 1.05f) {
event.changes.forEach { it.consume() } event.changes.forEach { it.consume() }
} }
@@ -687,7 +695,6 @@ private fun ZoomableImage(
} }
} while (event.changes.any { it.pressed }) } while (event.changes.any { it.pressed })
// Pointer up - end drag
if (isVerticalDragging) { if (isVerticalDragging) {
isVerticalDragging = false isVerticalDragging = false
onDragEnd() onDragEnd()
@@ -819,7 +826,8 @@ fun extractImagesFromMessages(
senderName = if (message.isOutgoing) "You" else opponentName, senderName = if (message.isOutgoing) "You" else opponentName,
timestamp = message.timestamp, timestamp = message.timestamp,
width = attachment.width, width = attachment.width,
height = attachment.height height = attachment.height,
caption = message.text
) )
} }
} }

View File

@@ -4,6 +4,7 @@ import android.app.Activity
import android.util.Log import android.util.Log
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween 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_EXPANDED_OTHER = 120.dp
private val AVATAR_SIZE_COLLAPSED_OTHER = 36.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) @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable @Composable
fun OtherProfileScreen( fun OtherProfileScreen(
@@ -474,12 +507,10 @@ private fun CollapsingOtherProfileHeader(
val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress) val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress)
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress) val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress)
// Определяем цвет текста на основе фона // ═══════════════════════════════════════════════════════════
val textColor by remember(hasAvatar, avatarColors) { // 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой
derivedStateOf { // ═══════════════════════════════════════════════════════════
if (isColorLight(avatarColors.backgroundColor)) Color.Black else Color.White val textColor = if (isDarkTheme) Color.White else Color.Black
}
}
Box(modifier = Modifier.fillMaxWidth().height(headerHeight)) { Box(modifier = Modifier.fillMaxWidth().height(headerHeight)) {
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════

View File

@@ -112,6 +112,41 @@ fun isColorLight(color: Color): Boolean {
return luminance > 0.5f 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 { fun getAvatarColor(name: String, isDarkTheme: Boolean): AvatarColors {
val cacheKey = "${name}_${if (isDarkTheme) "dark" else "light"}" val cacheKey = "${name}_${if (isDarkTheme) "dark" else "light"}"
return avatarColorCache.getOrPut(cacheKey) { return avatarColorCache.getOrPut(cacheKey) {
@@ -800,62 +835,9 @@ private fun CollapsingProfileHeader(
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 🎨 DOMINANT COLOR - извлекаем из аватарки для контраста текста // 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val avatars by val textColor = if (isDarkTheme) Color.White else Color.Black
avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) }
var avatarBitmap by remember(avatars) { mutableStateOf<android.graphics.Bitmap?>(null) }
var dominantColor by remember { mutableStateOf<Color?>(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
}
}
}
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 📐 HEADER HEIGHT - ФИКСИРОВАННАЯ! Не меняется при overscroll // 📐 HEADER HEIGHT - ФИКСИРОВАННАЯ! Не меняется при overscroll