fix: Update Java home path in gradle.properties for consistency
This commit is contained in:
@@ -191,6 +191,8 @@ fun ChatDetailScreen(
|
||||
// Telegram-style scroll tracking
|
||||
var wasManualScroll by remember { mutableStateOf(false) }
|
||||
var isAtBottom by remember { mutableStateOf(true) }
|
||||
// Флаг для скрытия кнопки scroll при отправке (чтобы не мигала)
|
||||
var isSendingMessage by remember { mutableStateOf(false) }
|
||||
|
||||
// Track if user is at bottom of list
|
||||
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
|
||||
@@ -232,19 +234,29 @@ fun ChatDetailScreen(
|
||||
val isOnline by viewModel.opponentOnline.collectAsState()
|
||||
|
||||
// 🔥 Добавляем информацию о датах к сообщениям
|
||||
// В reversed layout (новые внизу) - показываем дату ПОСЛЕ сообщения (визуально сверху)
|
||||
val messagesWithDates =
|
||||
remember(messages) {
|
||||
val result = mutableListOf<Pair<ChatMessage, Boolean>>() // message, showDateHeader
|
||||
var lastDateString = ""
|
||||
|
||||
// Сортируем по времени (старые -> новые)
|
||||
val sortedMessages = messages.sortedBy { it.timestamp.time }
|
||||
// Сортируем по времени (новые -> старые) для reversed layout
|
||||
val sortedMessages = messages.sortedByDescending { it.timestamp.time }
|
||||
|
||||
for (message in sortedMessages) {
|
||||
for (i in sortedMessages.indices) {
|
||||
val message = sortedMessages[i]
|
||||
val dateString =
|
||||
SimpleDateFormat("yyyyMMdd", Locale.getDefault())
|
||||
.format(message.timestamp)
|
||||
val showDate = dateString != lastDateString
|
||||
|
||||
// Показываем дату если это последнее сообщение за день
|
||||
// (следующее сообщение - другой день или нет следующего)
|
||||
val nextMessage = sortedMessages.getOrNull(i + 1)
|
||||
val nextDateString = nextMessage?.let {
|
||||
SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(it.timestamp)
|
||||
}
|
||||
val showDate = nextDateString == null || nextDateString != dateString
|
||||
|
||||
result.add(message to showDate)
|
||||
lastDateString = dateString
|
||||
}
|
||||
@@ -464,10 +476,15 @@ fun ChatDetailScreen(
|
||||
},
|
||||
containerColor = Color.Transparent
|
||||
) { paddingValues ->
|
||||
// Используем Box для overlay - инпут поверх контента
|
||||
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
|
||||
// Список сообщений - занимает всё пространство
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// 🔥 Column с imePadding - весь контент поднимается с клавиатурой
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.imePadding() // Контент поднимается над клавиатурой
|
||||
) {
|
||||
// Список сообщений - занимает всё доступное пространство
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
if (messages.isEmpty()) {
|
||||
// Пустое состояние
|
||||
Column(
|
||||
@@ -527,68 +544,115 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
),
|
||||
// Добавляем padding сверху и снизу для скролла под floating input
|
||||
// padding для контента списка
|
||||
contentPadding =
|
||||
PaddingValues(
|
||||
start = 8.dp,
|
||||
end = 8.dp,
|
||||
top = 8.dp,
|
||||
bottom = 80.dp // Отступ для floating input
|
||||
bottom = 8.dp
|
||||
),
|
||||
reverseLayout = true
|
||||
) {
|
||||
// Для inverted FlatList: идём от новых к старым
|
||||
val reversedMessages = messagesWithDates.reversed()
|
||||
itemsIndexed(reversedMessages, key = { _, item -> item.first.id }) {
|
||||
// Reversed layout: item 0 = самое новое сообщение (внизу экрана)
|
||||
// messagesWithDates уже отсортирован новые->старые
|
||||
itemsIndexed(messagesWithDates, key = { _, item -> item.first.id }) {
|
||||
index,
|
||||
(message, showDate) ->
|
||||
// В inverted списке дата показывается ПЕРЕД сообщением (визуально
|
||||
// ПОСЛЕ)
|
||||
Column {
|
||||
MessageBubble(
|
||||
message = message,
|
||||
isDarkTheme = isDarkTheme,
|
||||
index = index
|
||||
)
|
||||
// Разделитель даты
|
||||
// В reversed layout: дата показывается ПОСЛЕ сообщения
|
||||
// (визуально СВЕРХУ группы сообщений)
|
||||
if (showDate) {
|
||||
DateHeader(
|
||||
dateText = getDateText(message.timestamp.time),
|
||||
secondaryTextColor = secondaryTextColor
|
||||
)
|
||||
}
|
||||
MessageBubble(
|
||||
message = message,
|
||||
isDarkTheme = isDarkTheme,
|
||||
index = index
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram-style "Scroll to Bottom" кнопка
|
||||
if (!isAtBottom && messages.isNotEmpty()) {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
// Telegram-style "Scroll to Bottom" кнопка - Liquid Glass стиль
|
||||
// Не показываем при отправке сообщения (чтобы не мигала)
|
||||
if (!isAtBottom && messages.isNotEmpty() && !isSendingMessage) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.align(Alignment.BottomEnd)
|
||||
.padding(end = 16.dp, bottom = 16.dp)
|
||||
.size(44.dp)
|
||||
.shadow(
|
||||
elevation = 8.dp,
|
||||
shape = CircleShape,
|
||||
clip = false,
|
||||
ambientColor = Color.Black.copy(alpha = 0.3f),
|
||||
spotColor = Color.Black.copy(alpha = 0.3f)
|
||||
)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
brush =
|
||||
Brush.verticalGradient(
|
||||
colors =
|
||||
if (isDarkTheme) {
|
||||
listOf(
|
||||
Color(0xFF2D2D2F)
|
||||
.copy(alpha = 0.92f),
|
||||
Color(0xFF1C1C1E)
|
||||
.copy(alpha = 0.96f)
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Color(0xFFF2F2F7)
|
||||
.copy(alpha = 0.94f),
|
||||
Color(0xFFE5E5EA)
|
||||
.copy(alpha = 0.97f)
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
brush =
|
||||
Brush.verticalGradient(
|
||||
colors =
|
||||
if (isDarkTheme) {
|
||||
listOf(
|
||||
Color.White.copy(alpha = 0.18f),
|
||||
Color.White.copy(alpha = 0.06f)
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Color.White.copy(alpha = 0.9f),
|
||||
Color.Black.copy(alpha = 0.05f)
|
||||
)
|
||||
}
|
||||
),
|
||||
shape = CircleShape
|
||||
)
|
||||
.clickable {
|
||||
scope.launch {
|
||||
wasManualScroll = false
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier.align(Alignment.BottomEnd)
|
||||
.padding(end = 16.dp, bottom = 16.dp)
|
||||
.size(48.dp),
|
||||
containerColor = PrimaryBlue,
|
||||
elevation = FloatingActionButtonDefaults.elevation(6.dp)
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = "Scroll to bottom",
|
||||
tint = Color.White
|
||||
tint = if (isDarkTheme) Color.White.copy(alpha = 0.85f) else Color.Black.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 FLOATING INPUT - поверх контента внизу экрана
|
||||
Box(modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth()) {
|
||||
// 🔥 INPUT BAR - внизу Column, автоматически над клавиатурой
|
||||
MessageInputBar(
|
||||
value = inputText,
|
||||
onValueChange = {
|
||||
@@ -599,8 +663,16 @@ fun ChatDetailScreen(
|
||||
}
|
||||
},
|
||||
onSend = {
|
||||
// Скрываем кнопку scroll на время отправки
|
||||
isSendingMessage = true
|
||||
viewModel.sendMessage()
|
||||
// ProtocolManager.addLog("📤 Sending message...")
|
||||
// Скроллим к новому сообщению
|
||||
scope.launch {
|
||||
delay(100)
|
||||
listState.animateScrollToItem(0)
|
||||
delay(300) // Ждём завершения анимации
|
||||
isSendingMessage = false
|
||||
}
|
||||
},
|
||||
isDarkTheme = isDarkTheme,
|
||||
backgroundColor = inputBackgroundColor,
|
||||
@@ -609,7 +681,6 @@ fun ChatDetailScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} // Закрытие Box с fade-in
|
||||
|
||||
// Диалог логов
|
||||
@@ -808,8 +879,14 @@ private fun DateHeader(dateText: String, secondaryTextColor: Color) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Панель ввода сообщения 1:1 как в React Native Оптимизированная версия с правильным
|
||||
* позиционированием
|
||||
* Панель ввода сообщения - Telegram UX канон
|
||||
*
|
||||
* Золотые правила:
|
||||
* 1. Инпут всегда связан с клавиатурой (imePadding)
|
||||
* 2. Последнее сообщение всегда видно
|
||||
* 3. Никаких прыжков layout'а
|
||||
* 4. После отправки: инпут очищается, клавиатура НЕ закрывается
|
||||
* 5. Инпут растёт вверх при многострочном тексте (до 6 строк)
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
@@ -827,80 +904,30 @@ private fun MessageInputBar(
|
||||
val focusManager = LocalFocusManager.current
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
// Цвета - Telegram liquid glass style
|
||||
val circleBackground =
|
||||
if (isDarkTheme) Color(0xFF1F1F1F).copy(alpha = 0.75f)
|
||||
else Color(0xFFF0F0F0).copy(alpha = 0.85f)
|
||||
val circleBorder =
|
||||
if (isDarkTheme) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.08f)
|
||||
val circleIcon = if (isDarkTheme) Color.White.copy(alpha = 0.85f) else Color(0xFF333333)
|
||||
|
||||
// Liquid glass input - темнее и с эффектом размытия
|
||||
val glassBackground =
|
||||
if (isDarkTheme) Color(0xFF1A1A1A).copy(alpha = 0.88f)
|
||||
else Color(0xFFF0F0F0).copy(alpha = 0.92f)
|
||||
val glassBorder =
|
||||
if (isDarkTheme) Color.White.copy(alpha = 0.12f) else Color.Black.copy(alpha = 0.08f)
|
||||
val emojiIconColor =
|
||||
if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color.Black.copy(alpha = 0.5f)
|
||||
val panelBackground =
|
||||
if (isDarkTheme) Color(0xFF0E0E0E).copy(alpha = 0.95f)
|
||||
else Color(0xFFF5F5F5).copy(alpha = 0.95f)
|
||||
|
||||
// Состояние отправки
|
||||
val canSend = remember(value) { value.isNotBlank() }
|
||||
|
||||
// Easing
|
||||
// Easing анимации
|
||||
val backEasing = CubicBezierEasing(0.34f, 1.56f, 0.64f, 1f)
|
||||
val smoothEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f)
|
||||
|
||||
// Анимации Send
|
||||
val sendScale by
|
||||
animateFloatAsState(
|
||||
targetValue = if (canSend) 1f else 0f,
|
||||
animationSpec = tween(220, easing = backEasing),
|
||||
label = "sendScale"
|
||||
)
|
||||
|
||||
// Анимации Mic
|
||||
val micOpacity by
|
||||
animateFloatAsState(
|
||||
targetValue = if (canSend) 0f else 1f,
|
||||
animationSpec = tween(200, easing = smoothEasing),
|
||||
label = "micOpacity"
|
||||
)
|
||||
val micTranslateX by
|
||||
animateFloatAsState(
|
||||
targetValue = if (canSend) 80f else 0f,
|
||||
animationSpec = tween(250, easing = smoothEasing),
|
||||
label = "micTranslateX"
|
||||
)
|
||||
|
||||
// Input margin - для правильного позиционирования как на скриншоте
|
||||
val inputEndMargin by
|
||||
animateDpAsState(
|
||||
targetValue = if (canSend) 0.dp else 44.dp,
|
||||
animationSpec = tween(220, easing = smoothEasing),
|
||||
label = "inputEndMargin"
|
||||
)
|
||||
|
||||
// Функция переключения emoji picker
|
||||
fun toggleEmojiPicker() {
|
||||
if (showEmojiPicker) {
|
||||
showEmojiPicker = false
|
||||
} else {
|
||||
// Скрываем клавиатуру и убираем фокус
|
||||
// Сначала скрываем клавиатуру, затем показываем emoji picker
|
||||
focusManager.clearFocus(force = true)
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus()
|
||||
// Небольшая задержка чтобы клавиатура успела закрыться
|
||||
showEmojiPicker = true
|
||||
}
|
||||
}
|
||||
|
||||
// Функция отправки
|
||||
// Функция отправки - НЕ закрывает клавиатуру (UX правило #6)
|
||||
fun handleSend() {
|
||||
if (value.isNotBlank()) {
|
||||
onSend()
|
||||
onValueChange("")
|
||||
// Очищаем инпут, но клавиатура остаётся открытой
|
||||
}
|
||||
}
|
||||
|
||||
@@ -908,14 +935,14 @@ private fun MessageInputBar(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.background(Color.Transparent)
|
||||
.then(if (!showEmojiPicker) Modifier.imePadding() else Modifier)
|
||||
// imePadding уже на родительском Column
|
||||
) {
|
||||
// 🔥 TELEGRAM-STYLE FLOATING LIQUID GLASS INPUT
|
||||
// Единый liquid glass контейнер без фона
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.padding(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 16.dp)
|
||||
// Инпут растёт вверх до 6 строк (~140dp)
|
||||
.heightIn(min = 44.dp, max = 140.dp)
|
||||
.shadow(
|
||||
elevation = 4.dp,
|
||||
@@ -926,36 +953,22 @@ private fun MessageInputBar(
|
||||
)
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.background(
|
||||
// Telegram glass effect - достаточно плотный но с эффектом
|
||||
// стекла
|
||||
brush =
|
||||
Brush.verticalGradient(
|
||||
colors =
|
||||
if (isDarkTheme) {
|
||||
listOf(
|
||||
Color(0xFF2D2D2F)
|
||||
.copy(
|
||||
alpha =
|
||||
0.92f
|
||||
),
|
||||
.copy(alpha = 0.92f),
|
||||
Color(0xFF1C1C1E)
|
||||
.copy(
|
||||
alpha =
|
||||
0.96f
|
||||
)
|
||||
.copy(alpha = 0.96f)
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Color(0xFFF2F2F7)
|
||||
.copy(
|
||||
alpha =
|
||||
0.94f
|
||||
),
|
||||
.copy(alpha = 0.94f),
|
||||
Color(0xFFE5E5EA)
|
||||
.copy(
|
||||
alpha =
|
||||
0.97f
|
||||
)
|
||||
.copy(alpha = 0.97f)
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -967,21 +980,13 @@ private fun MessageInputBar(
|
||||
colors =
|
||||
if (isDarkTheme) {
|
||||
listOf(
|
||||
Color.White.copy(
|
||||
alpha = 0.18f
|
||||
),
|
||||
Color.White.copy(
|
||||
alpha = 0.06f
|
||||
)
|
||||
Color.White.copy(alpha = 0.18f),
|
||||
Color.White.copy(alpha = 0.06f)
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Color.White.copy(
|
||||
alpha = 0.9f
|
||||
),
|
||||
Color.Black.copy(
|
||||
alpha = 0.05f
|
||||
)
|
||||
Color.White.copy(alpha = 0.9f),
|
||||
Color.Black.copy(alpha = 0.05f)
|
||||
)
|
||||
}
|
||||
),
|
||||
@@ -990,7 +995,7 @@ private fun MessageInputBar(
|
||||
.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
// EMOJI BUTTON - выравнивается по низу
|
||||
// EMOJI BUTTON
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.align(Alignment.Bottom)
|
||||
@@ -1017,7 +1022,7 @@ private fun MessageInputBar(
|
||||
)
|
||||
}
|
||||
|
||||
// TEXT INPUT - растягивается и центрируется вертикально
|
||||
// TEXT INPUT
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.weight(1f)
|
||||
@@ -1038,7 +1043,7 @@ private fun MessageInputBar(
|
||||
)
|
||||
}
|
||||
|
||||
// ATTACH BUTTON - выравнивается по низу
|
||||
// ATTACH BUTTON
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.align(Alignment.Bottom)
|
||||
@@ -1062,7 +1067,7 @@ private fun MessageInputBar(
|
||||
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
|
||||
// MIC / SEND BUTTON - Mic когда пусто, Send когда есть текст (Telegram style)
|
||||
// MIC / SEND BUTTON
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.align(Alignment.Bottom)
|
||||
@@ -1080,9 +1085,7 @@ private fun MessageInputBar(
|
||||
indication = null,
|
||||
onClick = {
|
||||
if (canSend) handleSend()
|
||||
else {
|
||||
/* TODO: Start voice recording */
|
||||
}
|
||||
else { /* TODO: Voice recording */ }
|
||||
}
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
@@ -1093,7 +1096,6 @@ private fun MessageInputBar(
|
||||
label = "iconCrossfade"
|
||||
) { showSend ->
|
||||
if (showSend) {
|
||||
// Telegram Send icon - кастомная SVG
|
||||
Icon(
|
||||
imageVector = TelegramSendIcon,
|
||||
contentDescription = "Send",
|
||||
@@ -1101,7 +1103,6 @@ private fun MessageInputBar(
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
} else {
|
||||
// Mic icon
|
||||
Icon(
|
||||
Icons.Default.Mic,
|
||||
contentDescription = "Voice",
|
||||
|
||||
@@ -236,11 +236,15 @@ fun AppleEmojiText(
|
||||
val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f
|
||||
else fontSize.value
|
||||
|
||||
// Минимальная высота для корректного отображения emoji
|
||||
val minHeight = (fontSizeValue * 1.5).toInt()
|
||||
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
AppleEmojiTextView(ctx).apply {
|
||||
setTextColor(color.toArgb())
|
||||
setTextSize(fontSizeValue)
|
||||
minimumHeight = (minHeight * ctx.resources.displayMetrics.density).toInt()
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
@@ -265,6 +269,11 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
private val bitmapCache = LruCache<String, Bitmap>(100)
|
||||
}
|
||||
|
||||
init {
|
||||
// Отключаем лишние отступы шрифта для корректного отображения emoji
|
||||
includeFontPadding = false
|
||||
}
|
||||
|
||||
fun setTextWithEmojis(text: String) {
|
||||
val spannable = SpannableStringBuilder(text)
|
||||
val matcher = EMOJI_PATTERN.matcher(text)
|
||||
@@ -275,12 +284,13 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
val bitmap = loadEmojiBitmap(unified)
|
||||
|
||||
if (bitmap != null) {
|
||||
val size = (textSize * 1.2).toInt()
|
||||
val size = (textSize * 1.3).toInt() // Увеличиваем размер emoji
|
||||
val scaledBitmap = Bitmap.createScaledBitmap(bitmap, size, size, true)
|
||||
val drawable = BitmapDrawable(resources, scaledBitmap)
|
||||
drawable.setBounds(0, 0, size, size)
|
||||
|
||||
val span = ImageSpan(drawable, ImageSpan.ALIGN_BASELINE)
|
||||
// ALIGN_BOTTOM лучше работает с emoji - не обрезает сверху
|
||||
val span = ImageSpan(drawable, ImageSpan.ALIGN_BOTTOM)
|
||||
spannable.setSpan(span, matcher.start(), matcher.end(),
|
||||
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
|
||||
# Use Java 17 for build
|
||||
org.gradle.java.home=/opt/homebrew/Cellar/openjdk@17/17.0.14/libexec/openjdk.jdk/Contents/Home
|
||||
org.gradle.java.home=/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home
|
||||
|
||||
# Increase heap size for Gradle
|
||||
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError --add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
|
||||
|
||||
Reference in New Issue
Block a user