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,
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 ->

View File

@@ -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
when {
// 1. Если emoji picker открыт - закрываем только его
showEmojiPicker -> {
showEmojiPicker = false
}
// 2. Если клавиатура открыта - закрываем только её
isKeyboardVisible -> {
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus()
// Всегда закрываем экран
}
// 3. Иначе - выходим с экрана
else -> {
animatedDismiss()
}
}
},
modifier = Modifier.align(Alignment.CenterStart)
) {
@@ -542,53 +559,34 @@ fun ImageEditorScreen(
// 🔥 FIX: Сырое значение - emoji показан ИЛИ box виден
val rawEmojiVisible = showEmojiPicker || coordinator.isEmojiBoxVisible
// 🔥 FIX: Debounced флаг для emoji - с задержкой при закрытии чтобы не мигал
var stableShowEmojiPicker by remember { mutableStateOf(rawEmojiVisible) }
// ═══════════════════════════════════════════════════════════════
// 🎬 УПРОЩЁННАЯ АНИМАЦИЯ ИНПУТА
// - Позиция: imePadding() + toolbarOffset (привязан к реальной высоте IME)
// - Визуал: progress анимирует цвет, углы, иконки
// ═══════════════════════════════════════════════════════════════
LaunchedEffect(rawEmojiVisible) {
if (rawEmojiVisible) {
// Открытие emoji - мгновенно
stableShowEmojiPicker = true
} else {
// Закрытие emoji - с небольшой задержкой для плавности
delay(50)
stableShowEmojiPicker = false
}
}
// Состояние: развёрнуто когда клавиатура ИЛИ emoji видны
val targetExpanded = isKeyboardVisible || rawEmojiVisible
// 🔥 FIX: Debounced флаг для стилей инпута - предотвращает мигание при закрытии emoji
// Истинный когда клавиатура ИЛИ emoji открыты
val isInputExpanded = isKeyboardVisible || stableShowEmojiPicker
// Simple state-based padding (like ChatDetailInput)
val shouldAddNavBarPadding = !isKeyboardVisible && !coordinator.isEmojiBoxVisible
// 🔥 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"
// 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)
)
// 📊 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
) {
// <EFBFBD> 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"
)
// Corner radius: 24dp (compact) → 0dp (expanded)
val cornerRadius = lerp(24f, 0f, progress).dp
// 🔥 FIX: Анимируем прозрачность и цвет фона для плавного перехода
val backgroundAlpha by animateFloatAsState(
targetValue = if (isKeyboardVisible) 0.75f else 0.85f,
animationSpec = tween(250, easing = FastOutSlowInEasing),
label = "bgAlpha"
)
// Horizontal padding: 12dp (compact) → 0dp (expanded)
val horizontalPadding = lerp(12f, 0f, progress).dp
// 🔥 FIX: Плавный переход цвета фона
val backgroundColor by animateColorAsState(
targetValue = if (isKeyboardVisible) Color.Black else Color(0xFF2C2C2E),
animationSpec = tween(250, easing = FastOutSlowInEasing),
label = "bgColor"
)
// 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,46 +1055,42 @@ 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)
// 🎬 Левая иконка: плавная анимация через alpha
// Камера (compact) ↔ Emoji/Keyboard (expanded)
Box(
modifier = Modifier.size(32.dp),
contentAlignment = Alignment.Center
) {
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) {
// Камера - видна когда progress близок к 0
Icon(
TablerIcons.CameraPlus,
contentDescription = "Camera",
tint = Color.White.copy(alpha = 0.7f),
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
@@ -1132,15 +1116,15 @@ private fun TelegramCaptionBar(
)
}
// 🔥 FIX: Crossfade вместо AnimatedContent для кнопки отправки
Crossfade(
targetState = isKeyboardVisible,
animationSpec = tween(200),
label = "send_button"
) { keyboardOpen ->
// 🎬 Кнопка отправки: плавная анимация размера и иконки через 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(if (keyboardOpen) 32.dp else 36.dp)
.size(buttonSize)
.clip(CircleShape)
.background(PrimaryBlue)
.clickable(enabled = !isSaving) { onSend() },
@@ -1148,19 +1132,31 @@ private fun TelegramCaptionBar(
) {
if (isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(if (keyboardOpen) 18.dp else 20.dp),
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
}
}

View File

@@ -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 = "" // Текст сообщения для отображения снизу
)
/**
@@ -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(
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
) {
// 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)
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.5f))
.padding(horizontal = 16.dp, vertical = 16.dp)
) {
AppleEmojiText(
text = currentCaption,
color = Color.White,
fontSize = 15.sp,
maxLines = 4,
overflow = android.text.TextUtils.TruncateAt.END
)
)
}
}
// Показываем счетчик под точками если больше 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 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,10 +599,9 @@ 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
@@ -627,6 +618,8 @@ private fun ZoomableImage(
if (!canceled) {
val zoomChange = event.calculateZoom()
val panChange = event.calculatePan()
// 🔥 Telegram-style: получаем центр жеста для zoom к точке
val centroid = event.calculateCentroid(useCurrent = false)
if (!pastTouchSlop) {
zoom *= zoomChange
@@ -637,7 +630,7 @@ private fun ZoomableImage(
if (touchMoved || zoomMotion) {
pastTouchSlop = true
// Decide: vertical dismiss or zoom/pan?
// Vertical dismiss only when not zoomed
if (scale <= 1.05f && zoomChange == 1f &&
abs(panChange.y) > abs(panChange.x) * 1.5f) {
lockedToDismiss = true
@@ -648,37 +641,52 @@ private fun ZoomableImage(
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
offsetX = newOffsetX.coerceIn(-maxX, maxX)
offsetY = newOffsetY.coerceIn(-maxY, maxY)
// Consume if zoomed to prevent pager swipe
if (scale > 1.05f) {
event.changes.forEach { it.consume() }
}
@@ -687,7 +695,6 @@ private fun ZoomableImage(
}
} while (event.changes.any { it.pressed })
// Pointer up - end drag
if (isVerticalDragging) {
isVerticalDragging = false
onDragEnd()
@@ -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
)
}
}

View File

@@ -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)) {
// ═══════════════════════════════════════════════════════════

View File

@@ -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<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
}
}
}
val textColor = if (isDarkTheme) Color.White else Color.Black
// ═══════════════════════════════════════════════════════════
// 📐 HEADER HEIGHT - ФИКСИРОВАННАЯ! Не меняется при overscroll