fix: add caption support to image viewer with animated display
This commit is contained in:
@@ -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 ->
|
||||
|
||||
@@ -74,6 +74,9 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
private const val TAG = "ImageEditorScreen"
|
||||
|
||||
/** Linear interpolation between two values */
|
||||
private fun lerp(start: Float, stop: Float, fraction: Float): Float = start + (stop - start) * fraction
|
||||
|
||||
/** Telegram-style easing */
|
||||
private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f)
|
||||
|
||||
@@ -159,28 +162,31 @@ fun ImageEditorScreen(
|
||||
}
|
||||
|
||||
// 🎨 Черный статус бар и навигационный бар для редактора
|
||||
val window = remember { (view.context as? Activity)?.window }
|
||||
DisposableEffect(Unit) {
|
||||
val originalStatusBarColor = window?.statusBarColor ?: 0
|
||||
val originalNavigationBarColor = window?.navigationBarColor ?: 0
|
||||
val activity = context as? Activity
|
||||
val window = activity?.window
|
||||
DisposableEffect(window) {
|
||||
if (window == null) return@DisposableEffect onDispose { }
|
||||
|
||||
val originalStatusBarColor = window.statusBarColor
|
||||
val originalNavigationBarColor = window.navigationBarColor
|
||||
|
||||
// 🔥 Сохраняем оригинальное состояние иконок статус бара
|
||||
val insetsController = window?.let { WindowCompat.getInsetsController(it, view) }
|
||||
val originalLightStatusBars = insetsController?.isAppearanceLightStatusBars ?: false
|
||||
val originalLightNavigationBars = insetsController?.isAppearanceLightNavigationBars ?: false
|
||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||
val originalLightStatusBars = insetsController.isAppearanceLightStatusBars
|
||||
val originalLightNavigationBars = insetsController.isAppearanceLightNavigationBars
|
||||
|
||||
// Устанавливаем черный цвет и светлые иконки
|
||||
window?.statusBarColor = android.graphics.Color.BLACK
|
||||
window?.navigationBarColor = android.graphics.Color.BLACK
|
||||
insetsController?.isAppearanceLightStatusBars = false // Светлые иконки на черном фоне
|
||||
insetsController?.isAppearanceLightNavigationBars = false
|
||||
window.statusBarColor = android.graphics.Color.BLACK
|
||||
window.navigationBarColor = android.graphics.Color.BLACK
|
||||
insetsController.isAppearanceLightStatusBars = false // Светлые иконки на черном фоне
|
||||
insetsController.isAppearanceLightNavigationBars = false
|
||||
|
||||
onDispose {
|
||||
// Восстанавливаем оригинальные цвета и состояние иконок
|
||||
window?.statusBarColor = originalStatusBarColor
|
||||
window?.navigationBarColor = originalNavigationBarColor
|
||||
insetsController?.isAppearanceLightStatusBars = originalLightStatusBars
|
||||
insetsController?.isAppearanceLightNavigationBars = originalLightNavigationBars
|
||||
window.statusBarColor = originalStatusBarColor
|
||||
window.navigationBarColor = originalNavigationBarColor
|
||||
insetsController.isAppearanceLightStatusBars = originalLightStatusBars
|
||||
insetsController.isAppearanceLightNavigationBars = originalLightNavigationBars
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +234,7 @@ fun ImageEditorScreen(
|
||||
// Update coordinator through snapshotFlow
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { with(density) { imeInsets.getBottom(density).toDp() } }.collect { currentImeHeight ->
|
||||
val wasKeyboardVisible = isKeyboardVisible
|
||||
isKeyboardVisible = currentImeHeight > 50.dp
|
||||
coordinator.updateKeyboardHeight(currentImeHeight)
|
||||
if (currentImeHeight > 100.dp) {
|
||||
@@ -235,7 +242,10 @@ fun ImageEditorScreen(
|
||||
lastStableKeyboardHeight = currentImeHeight
|
||||
}
|
||||
// 📊 Log IME height changes
|
||||
Log.d(TAG, "IME height: ${currentImeHeight.value}dp, isKeyboardVisible: $isKeyboardVisible, emojiHeight: ${coordinator.emojiHeight.value}dp, isEmojiBoxVisible: ${coordinator.isEmojiBoxVisible}")
|
||||
Log.d(TAG, "⌨️ IME: height=${currentImeHeight.value}dp, wasVisible=$wasKeyboardVisible, isVisible=$isKeyboardVisible, emojiBoxVisible=${coordinator.isEmojiBoxVisible}")
|
||||
if (wasKeyboardVisible != isKeyboardVisible) {
|
||||
Log.d(TAG, "⌨️ KEYBOARD STATE CHANGED: $wasKeyboardVisible → $isKeyboardVisible")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,19 +433,26 @@ fun ImageEditorScreen(
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 4.dp, vertical = 8.dp)
|
||||
) {
|
||||
// Close button (X) - сначала закрывает emoji/клавиатуру, потом экран
|
||||
// Close button (X) - умное закрытие: клавиатура → emoji → экран
|
||||
IconButton(
|
||||
onClick = {
|
||||
// Закрываем emoji picker если открыт
|
||||
showEmojiPicker = false
|
||||
|
||||
// Закрываем клавиатуру если открыта
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
focusManager.clearFocus()
|
||||
|
||||
// Всегда закрываем экран
|
||||
animatedDismiss()
|
||||
when {
|
||||
// 1. Если emoji picker открыт - закрываем только его
|
||||
showEmojiPicker -> {
|
||||
showEmojiPicker = false
|
||||
}
|
||||
// 2. Если клавиатура открыта - закрываем только её
|
||||
isKeyboardVisible -> {
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
// 3. Иначе - выходим с экрана
|
||||
else -> {
|
||||
animatedDismiss()
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.align(Alignment.CenterStart)
|
||||
) {
|
||||
@@ -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
|
||||
|
||||
// Simple state-based padding (like ChatDetailInput)
|
||||
val shouldAddNavBarPadding = !isKeyboardVisible && !coordinator.isEmojiBoxVisible
|
||||
|
||||
// Progress для ВИЗУАЛЬНЫХ изменений (цвет, углы, иконки)
|
||||
val visualProgress = remember { Animatable(0f) }
|
||||
LaunchedEffect(targetExpanded) {
|
||||
val targetValue = if (targetExpanded) 1f else 0f
|
||||
if (visualProgress.value != targetValue) {
|
||||
visualProgress.animateTo(
|
||||
targetValue = targetValue,
|
||||
animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 FIX: Debounced флаг для стилей инпута - предотвращает мигание при закрытии emoji
|
||||
// Истинный когда клавиатура ИЛИ emoji открыты
|
||||
val isInputExpanded = isKeyboardVisible || stableShowEmojiPicker
|
||||
|
||||
// 🔥 FIX: Инициализируем с актуальным значением чтобы не было мигания при первом рендере
|
||||
var isInputExpandedDebounced by remember { mutableStateOf(isInputExpanded) }
|
||||
|
||||
LaunchedEffect(isInputExpanded) {
|
||||
if (isInputExpanded) {
|
||||
// Открытие - мгновенно
|
||||
isInputExpandedDebounced = true
|
||||
} else {
|
||||
// Закрытие - с задержкой чтобы анимация emoji завершилась
|
||||
delay(300)
|
||||
isInputExpandedDebounced = false
|
||||
}
|
||||
}
|
||||
|
||||
// Когда клавиатура/emoji закрыты - добавляем отступ снизу для toolbar (~100dp)
|
||||
// 🔥 Анимируем отступ для плавного перехода
|
||||
val bottomPaddingForCaption by animateDpAsState(
|
||||
targetValue = if (!isInputExpandedDebounced) 100.dp else 0.dp,
|
||||
animationSpec = tween(200, easing = FastOutSlowInEasing),
|
||||
label = "bottomPadding"
|
||||
)
|
||||
|
||||
// 📊 Log render state
|
||||
Log.d(TAG, "RENDER: showEmoji=$showEmojiPicker, stableEmoji=$stableShowEmojiPicker, isKeyboard=$isKeyboardVisible, isEmojiBoxVisible=${coordinator.isEmojiBoxVisible}, useImePadding=$shouldUseImePadding, emojiHeight=${coordinator.emojiHeight.value}dp, bottomPadding=${bottomPaddingForCaption.value}dp")
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showCaptionInput && currentTool == EditorTool.NONE,
|
||||
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(),
|
||||
enter = fadeIn(tween(200)) + expandVertically(expandFrom = Alignment.Bottom),
|
||||
exit = fadeOut(tween(150)) + shrinkVertically(shrinkTowards = Alignment.Bottom),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
@@ -596,19 +594,19 @@ fun ImageEditorScreen(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
// 🔥 Отступ для toolbar ТОЛЬКО когда клавиатура/emoji закрыты
|
||||
.padding(bottom = bottomPaddingForCaption)
|
||||
// 🔥 imePadding поднимает над клавиатурой (применяется только когда emoji не показан)
|
||||
// Fixed padding: 0dp when keyboard/emoji shown, 100dp for toolbar when closed
|
||||
.padding(bottom = if (isKeyboardVisible || rawEmojiVisible) 0.dp else 100.dp)
|
||||
// imePadding for keyboard (only when emoji box not visible)
|
||||
.then(if (shouldUseImePadding) Modifier.imePadding() else Modifier)
|
||||
) {
|
||||
TelegramCaptionBar(
|
||||
caption = caption,
|
||||
onCaptionChange = { caption = it },
|
||||
isSaving = isSaving,
|
||||
// 🔥 Используем debounced флаг чтобы стили не мигали при закрытии emoji
|
||||
isKeyboardVisible = isInputExpandedDebounced,
|
||||
// 🔥 FIX: Используем стабильный флаг чтобы иконка не мигала
|
||||
showEmojiPicker = stableShowEmojiPicker,
|
||||
// 🔥 Progress только для визуальных изменений (цвет, углы, иконки)
|
||||
transitionProgress = visualProgress.value,
|
||||
// Для иконки emoji/keyboard - используем актуальное состояние emoji
|
||||
showEmojiPicker = rawEmojiVisible,
|
||||
onToggleEmojiPicker = { toggleEmojiPicker() },
|
||||
onEditTextViewCreated = { editTextView = it },
|
||||
onSend = {
|
||||
@@ -1011,47 +1009,37 @@ private fun TelegramRotateBar(
|
||||
|
||||
/**
|
||||
* Telegram-style caption input bar with emoji support
|
||||
* Меняет внешний вид в зависимости от состояния клавиатуры:
|
||||
* - Клавиатура закрыта: стеклянный инпут с blur эффектом (не на всю ширину)
|
||||
* - Клавиатура открыта: полный стиль (emoji + текст + галочка)
|
||||
* Использует единый transitionProgress для синхронной анимации всех параметров:
|
||||
* - 0f = компактный режим (клавиатура закрыта, "Add a caption" капсула)
|
||||
* - 1f = развёрнутый режим (клавиатура/emoji открыты, полный инпут)
|
||||
*/
|
||||
@Composable
|
||||
private fun TelegramCaptionBar(
|
||||
caption: String,
|
||||
onCaptionChange: (String) -> Unit,
|
||||
isSaving: Boolean,
|
||||
isKeyboardVisible: Boolean,
|
||||
transitionProgress: Float, // 0f = compact, 1f = expanded
|
||||
showEmojiPicker: Boolean = false,
|
||||
onToggleEmojiPicker: () -> Unit = {},
|
||||
onEditTextViewCreated: ((com.rosetta.messenger.ui.components.AppleEmojiEditTextView) -> Unit)? = null,
|
||||
onSend: () -> Unit
|
||||
) {
|
||||
// <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)
|
||||
) {
|
||||
Icon(
|
||||
if (emojiOpen) TablerIcons.Keyboard else TablerIcons.MoodSmile,
|
||||
contentDescription = if (emojiOpen) "Keyboard" else "Emoji",
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(26.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Клавиатура закрыта - камера иконка (с одинаковым размером для избежания прыжка)
|
||||
Box(modifier = Modifier.size(32.dp), contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
TablerIcons.CameraPlus,
|
||||
contentDescription = "Camera",
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(26.dp)
|
||||
)
|
||||
}
|
||||
// 🎬 Левая иконка: плавная анимация через alpha
|
||||
// Камера (compact) ↔ Emoji/Keyboard (expanded)
|
||||
Box(
|
||||
modifier = Modifier.size(32.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// Камера - видна когда progress близок к 0
|
||||
Icon(
|
||||
TablerIcons.CameraPlus,
|
||||
contentDescription = "Camera",
|
||||
tint = Color.White.copy(alpha = 0.7f * (1f - progress)),
|
||||
modifier = Modifier
|
||||
.size(26.dp)
|
||||
.graphicsLayer { alpha = 1f - progress }
|
||||
)
|
||||
|
||||
// Emoji/Keyboard toggle - виден когда progress близок к 1
|
||||
IconButton(
|
||||
onClick = onToggleEmojiPicker,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.graphicsLayer { alpha = progress },
|
||||
enabled = progress > 0.5f // Кликабелен только когда достаточно виден
|
||||
) {
|
||||
Icon(
|
||||
if (showEmojiPicker) TablerIcons.Keyboard else TablerIcons.MoodSmile,
|
||||
contentDescription = if (showEmojiPicker) "Keyboard" else "Emoji",
|
||||
tint = Color.White.copy(alpha = 0.7f * progress),
|
||||
modifier = Modifier.size(26.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Caption text field - использует AppleEmojiTextField для правильной работы с фокусом
|
||||
// 🔥 FIX: Анимируем высоту текстового поля
|
||||
val textFieldMaxHeight by animateDpAsState(
|
||||
targetValue = if (isKeyboardVisible) 100.dp else 24.dp,
|
||||
animationSpec = tween(200, easing = FastOutSlowInEasing),
|
||||
label = "textFieldHeight"
|
||||
)
|
||||
// 🎬 Высота через lerp для синхронной анимации
|
||||
val textFieldMaxHeight = lerp(24f, 100f, progress).dp
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -1132,35 +1116,47 @@ private fun TelegramCaptionBar(
|
||||
)
|
||||
}
|
||||
|
||||
// 🔥 FIX: Crossfade вместо AnimatedContent для кнопки отправки
|
||||
Crossfade(
|
||||
targetState = isKeyboardVisible,
|
||||
animationSpec = tween(200),
|
||||
label = "send_button"
|
||||
) { keyboardOpen ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(if (keyboardOpen) 32.dp else 36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(PrimaryBlue)
|
||||
.clickable(enabled = !isSaving) { onSend() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (isSaving) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(if (keyboardOpen) 18.dp else 20.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
// Клавиатура открыта - галочка, закрыта - стрелка отправки
|
||||
// 🎬 Кнопка отправки: плавная анимация размера и иконки через progress
|
||||
// Размер: 36dp (compact) → 32dp (expanded)
|
||||
val buttonSize = lerp(36f, 32f, progress).dp
|
||||
val iconSize = lerp(22f, 20f, progress).dp
|
||||
val progressIndicatorSize = lerp(20f, 18f, progress).dp
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(buttonSize)
|
||||
.clip(CircleShape)
|
||||
.background(PrimaryBlue)
|
||||
.clickable(enabled = !isSaving) { onSend() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (isSaving) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(progressIndicatorSize),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
// Плавное переключение иконок через alpha
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
// Стрелка отправки - видна когда compact (progress близок к 0)
|
||||
Icon(
|
||||
if (keyboardOpen) TablerIcons.Check else TablerIcons.Send,
|
||||
TablerIcons.Send,
|
||||
contentDescription = "Send",
|
||||
tint = Color.White,
|
||||
tint = Color.White.copy(alpha = 1f - progress),
|
||||
modifier = Modifier
|
||||
.size(if (keyboardOpen) 20.dp else 22.dp)
|
||||
.then(if (!keyboardOpen) Modifier.offset(x = 1.dp) else Modifier)
|
||||
.size(iconSize)
|
||||
.offset(x = 1.dp)
|
||||
.graphicsLayer { alpha = 1f - progress }
|
||||
)
|
||||
// Галочка - видна когда expanded (progress близок к 1)
|
||||
Icon(
|
||||
TablerIcons.Check,
|
||||
contentDescription = "Done",
|
||||
tint = Color.White.copy(alpha = progress),
|
||||
modifier = Modifier
|
||||
.size(iconSize)
|
||||
.graphicsLayer { alpha = progress }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1427,28 +1423,31 @@ fun MultiImageEditorScreen(
|
||||
val view = LocalView.current
|
||||
|
||||
// 🎨 Черный статус бар и навигационный бар для редактора
|
||||
val window = remember { (view.context as? Activity)?.window }
|
||||
DisposableEffect(Unit) {
|
||||
val originalStatusBarColor = window?.statusBarColor ?: 0
|
||||
val originalNavigationBarColor = window?.navigationBarColor ?: 0
|
||||
val activity = context as? Activity
|
||||
val window = activity?.window
|
||||
DisposableEffect(window) {
|
||||
if (window == null) return@DisposableEffect onDispose { }
|
||||
|
||||
val originalStatusBarColor = window.statusBarColor
|
||||
val originalNavigationBarColor = window.navigationBarColor
|
||||
|
||||
// 🔥 Сохраняем оригинальное состояние иконок статус бара
|
||||
val insetsController = window?.let { WindowCompat.getInsetsController(it, view) }
|
||||
val originalLightStatusBars = insetsController?.isAppearanceLightStatusBars ?: false
|
||||
val originalLightNavigationBars = insetsController?.isAppearanceLightNavigationBars ?: false
|
||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||
val originalLightStatusBars = insetsController.isAppearanceLightStatusBars
|
||||
val originalLightNavigationBars = insetsController.isAppearanceLightNavigationBars
|
||||
|
||||
// Устанавливаем черный цвет и светлые иконки
|
||||
window?.statusBarColor = android.graphics.Color.BLACK
|
||||
window?.navigationBarColor = android.graphics.Color.BLACK
|
||||
insetsController?.isAppearanceLightStatusBars = false // Светлые иконки на черном фоне
|
||||
insetsController?.isAppearanceLightNavigationBars = false
|
||||
window.statusBarColor = android.graphics.Color.BLACK
|
||||
window.navigationBarColor = android.graphics.Color.BLACK
|
||||
insetsController.isAppearanceLightStatusBars = false // Светлые иконки на черном фоне
|
||||
insetsController.isAppearanceLightNavigationBars = false
|
||||
|
||||
onDispose {
|
||||
// Восстанавливаем оригинальные цвета и состояние иконок
|
||||
window?.statusBarColor = originalStatusBarColor
|
||||
window?.navigationBarColor = originalNavigationBarColor
|
||||
insetsController?.isAppearanceLightStatusBars = originalLightStatusBars
|
||||
insetsController?.isAppearanceLightNavigationBars = originalLightNavigationBars
|
||||
window.statusBarColor = originalStatusBarColor
|
||||
window.navigationBarColor = originalNavigationBarColor
|
||||
insetsController.isAppearanceLightStatusBars = originalLightStatusBars
|
||||
insetsController.isAppearanceLightNavigationBars = originalLightNavigationBars
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
) {
|
||||
// Dots indicator
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
Color.Black.copy(alpha = 0.5f),
|
||||
RoundedCornerShape(16.dp)
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
repeat(images.size.coerceAtMost(10)) { index ->
|
||||
val isSelected = pagerState.currentPage == index
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(if (isSelected) 8.dp else 6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isSelected) Color.White
|
||||
else Color.White.copy(alpha = 0.4f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
// Показываем счетчик под точками если больше 10 фото
|
||||
if (images.size > 10) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "${pagerState.currentPage + 1}/${images.size}",
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
AppleEmojiText(
|
||||
text = currentCaption,
|
||||
color = Color.White,
|
||||
fontSize = 15.sp,
|
||||
maxLines = 4,
|
||||
overflow = android.text.TextUtils.TruncateAt.END
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -469,20 +449,20 @@ private fun ZoomableImage(
|
||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||
var offsetY by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
// Animated values for smooth transitions
|
||||
// 🔥 Telegram-style smooth animations
|
||||
val animatedScale by animateFloatAsState(
|
||||
targetValue = scale,
|
||||
animationSpec = spring(dampingRatio = 0.8f, stiffness = 300f),
|
||||
animationSpec = spring(dampingRatio = 0.9f, stiffness = 400f),
|
||||
label = "scale"
|
||||
)
|
||||
val animatedOffsetX by animateFloatAsState(
|
||||
targetValue = offsetX,
|
||||
animationSpec = spring(dampingRatio = 0.8f, stiffness = 300f),
|
||||
animationSpec = spring(dampingRatio = 0.9f, stiffness = 400f),
|
||||
label = "offsetX"
|
||||
)
|
||||
val animatedOffsetY by animateFloatAsState(
|
||||
targetValue = offsetY,
|
||||
animationSpec = spring(dampingRatio = 0.8f, stiffness = 300f),
|
||||
animationSpec = spring(dampingRatio = 0.9f, stiffness = 400f),
|
||||
label = "offsetY"
|
||||
)
|
||||
|
||||
@@ -587,17 +567,29 @@ private fun ZoomableImage(
|
||||
onTap = { onTap() },
|
||||
onDoubleTap = { tapOffset ->
|
||||
if (scale > 1.1f) {
|
||||
// Zoom out
|
||||
// Zoom out - плавно возвращаемся
|
||||
scale = 1f
|
||||
offsetX = 0f
|
||||
offsetY = 0f
|
||||
} else {
|
||||
// Zoom in to tap point
|
||||
scale = 2.5f
|
||||
// 🔥 Telegram-style: zoom in к точке тапа
|
||||
val targetScale = 2.5f
|
||||
val centerX = containerSize.width / 2f
|
||||
val centerY = containerSize.height / 2f
|
||||
offsetX = (centerX - tapOffset.x) * 1.5f
|
||||
offsetY = (centerY - tapOffset.y) * 1.5f
|
||||
|
||||
// Вычисляем offset так, чтобы точка тапа осталась на месте
|
||||
val tapX = tapOffset.x - centerX
|
||||
val tapY = tapOffset.y - centerY
|
||||
|
||||
scale = targetScale
|
||||
offsetX = tapX - tapX * targetScale
|
||||
offsetY = tapY - tapY * targetScale
|
||||
|
||||
// Ограничиваем offset
|
||||
val maxX = (containerSize.width * (targetScale - 1) / 2f)
|
||||
val maxY = (containerSize.height * (targetScale - 1) / 2f)
|
||||
offsetX = offsetX.coerceIn(-maxX, maxX)
|
||||
offsetY = offsetY.coerceIn(-maxY, maxY)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -607,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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user