fix: add caption support to image viewer with animated display
This commit is contained in:
@@ -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 ->
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
// 1. Если emoji picker открыт - закрываем только его
|
||||||
// Всегда закрываем экран
|
showEmojiPicker -> {
|
||||||
animatedDismiss()
|
showEmojiPicker = false
|
||||||
|
}
|
||||||
|
// 2. Если клавиатура открыта - закрываем только её
|
||||||
|
isKeyboardVisible -> {
|
||||||
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
// 3. Иначе - выходим с экрана
|
||||||
|
else -> {
|
||||||
|
animatedDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.align(Alignment.CenterStart)
|
modifier = Modifier.align(Alignment.CenterStart)
|
||||||
) {
|
) {
|
||||||
@@ -538,57 +555,38 @@ fun ImageEditorScreen(
|
|||||||
|
|
||||||
// 🔥 КЛЮЧЕВОЕ: imePadding применяется ТОЛЬКО когда emoji НЕ показан
|
// 🔥 КЛЮЧЕВОЕ: imePadding применяется ТОЛЬКО когда emoji НЕ показан
|
||||||
val shouldUseImePadding = !coordinator.isEmojiBoxVisible
|
val shouldUseImePadding = !coordinator.isEmojiBoxVisible
|
||||||
|
|
||||||
// 🔥 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)
|
||||||
LaunchedEffect(rawEmojiVisible) {
|
// - Визуал: progress анимирует цвет, углы, иконки
|
||||||
if (rawEmojiVisible) {
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// Открытие emoji - мгновенно
|
|
||||||
stableShowEmojiPicker = true
|
// Состояние: развёрнуто когда клавиатура ИЛИ emoji видны
|
||||||
} else {
|
val targetExpanded = isKeyboardVisible || rawEmojiVisible
|
||||||
// Закрытие emoji - с небольшой задержкой для плавности
|
|
||||||
delay(50)
|
// Simple state-based padding (like ChatDetailInput)
|
||||||
stableShowEmojiPicker = false
|
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(
|
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"
|
// Horizontal padding: 12dp (compact) → 0dp (expanded)
|
||||||
)
|
val horizontalPadding = lerp(12f, 0f, progress).dp
|
||||||
|
|
||||||
// 🔥 FIX: Анимируем прозрачность и цвет фона для плавного перехода
|
// Background alpha: 0.85f (compact) → 0.75f (expanded)
|
||||||
val backgroundAlpha by animateFloatAsState(
|
val backgroundAlpha = lerp(0.85f, 0.75f, progress)
|
||||||
targetValue = if (isKeyboardVisible) 0.75f else 0.85f,
|
|
||||||
animationSpec = tween(250, easing = FastOutSlowInEasing),
|
// Background color: interpolate between compact and expanded colors
|
||||||
label = "bgAlpha"
|
val compactColor = Color(0xFF2C2C2E)
|
||||||
)
|
val expandedColor = Color.Black
|
||||||
|
val backgroundColor = androidx.compose.ui.graphics.lerp(compactColor, expandedColor, progress)
|
||||||
// 🔥 FIX: Плавный переход цвета фона
|
|
||||||
val backgroundColor by animateColorAsState(
|
|
||||||
targetValue = if (isKeyboardVisible) Color.Black else Color(0xFF2C2C2E),
|
|
||||||
animationSpec = tween(250, easing = FastOutSlowInEasing),
|
|
||||||
label = "bgColor"
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -1067,47 +1055,43 @@ 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
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = "" // Текст сообщения для отображения снизу
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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(
|
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,78 +599,94 @@ 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
|
||||||
currentVelocity = 0f
|
currentVelocity = 0f
|
||||||
|
|
||||||
var zoom = 1f
|
var zoom = 1f
|
||||||
var pastTouchSlop = false
|
var pastTouchSlop = false
|
||||||
var lockedToDismiss = false
|
var lockedToDismiss = false
|
||||||
|
|
||||||
do {
|
do {
|
||||||
val event = awaitPointerEvent()
|
val event = awaitPointerEvent()
|
||||||
val canceled = event.changes.any { it.isConsumed }
|
val canceled = event.changes.any { it.isConsumed }
|
||||||
|
|
||||||
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
|
||||||
val centroidSize = event.calculateCentroidSize(useCurrent = false)
|
val centroidSize = event.calculateCentroidSize(useCurrent = false)
|
||||||
val touchMoved = abs(panChange.x) > touchSlopValue || abs(panChange.y) > touchSlopValue
|
val touchMoved = abs(panChange.x) > touchSlopValue || abs(panChange.y) > touchSlopValue
|
||||||
val zoomMotion = abs(1 - zoom) * centroidSize > touchSlopValue
|
val zoomMotion = abs(1 - zoom) * centroidSize > touchSlopValue
|
||||||
|
|
||||||
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
|
||||||
isVerticalDragging = true
|
isVerticalDragging = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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() }
|
||||||
}
|
}
|
||||||
@@ -686,13 +694,12 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snap back if scale is close to 1
|
// Snap back if scale is close to 1
|
||||||
if (scale < 1.05f) {
|
if (scale < 1.05f) {
|
||||||
scale = 1f
|
scale = 1f
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) {
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user