feat: Add delivery confirmation for incoming messages in ProtocolManager
This commit is contained in:
@@ -5,6 +5,7 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -220,29 +221,38 @@ fun MainScreen(
|
||||
// Навигация между экранами
|
||||
var selectedUser by remember { mutableStateOf<SearchUser?>(null) }
|
||||
|
||||
// Анимированный переход между чатами
|
||||
// Анимированный переход между чатами - плавный crossfade без прыжков
|
||||
AnimatedContent(
|
||||
targetState = selectedUser,
|
||||
transitionSpec = {
|
||||
if (targetState != null) {
|
||||
// Открытие чата - слайд слева
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { it },
|
||||
animationSpec = tween(300)
|
||||
) togetherWith slideOutHorizontally(
|
||||
targetOffsetX = { -it / 3 },
|
||||
animationSpec = tween(300)
|
||||
// Плавный crossfade для избежания "прыжков" header'а
|
||||
val enterAnim = fadeIn(
|
||||
animationSpec = tween(
|
||||
durationMillis = 250,
|
||||
easing = FastOutSlowInEasing
|
||||
)
|
||||
} else {
|
||||
// Закрытие чата - слайд справа
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { -it / 3 },
|
||||
animationSpec = tween(300)
|
||||
) togetherWith slideOutHorizontally(
|
||||
targetOffsetX = { it },
|
||||
animationSpec = tween(300)
|
||||
) + slideInHorizontally(
|
||||
initialOffsetX = { if (targetState != null) it / 4 else -it / 4 },
|
||||
animationSpec = tween(
|
||||
durationMillis = 300,
|
||||
easing = FastOutSlowInEasing
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
val exitAnim = fadeOut(
|
||||
animationSpec = tween(
|
||||
durationMillis = 200,
|
||||
easing = FastOutSlowInEasing
|
||||
)
|
||||
) + slideOutHorizontally(
|
||||
targetOffsetX = { if (targetState != null) -it / 6 else it / 6 },
|
||||
animationSpec = tween(
|
||||
durationMillis = 300,
|
||||
easing = FastOutSlowInEasing
|
||||
)
|
||||
)
|
||||
|
||||
enterAnim togetherWith exitAnim using SizeTransform(clip = false)
|
||||
},
|
||||
label = "chatNavigation"
|
||||
) { user ->
|
||||
|
||||
@@ -364,6 +364,7 @@ class PacketRead : Packet() {
|
||||
/**
|
||||
* Delivery packet (ID: 0x08)
|
||||
* Уведомление о доставке сообщения
|
||||
* Порядок полей как в React Native: toPublicKey, messageId
|
||||
*/
|
||||
class PacketDelivery : Packet() {
|
||||
var messageId: String = ""
|
||||
@@ -372,15 +373,17 @@ class PacketDelivery : Packet() {
|
||||
override fun getPacketId(): Int = 0x08
|
||||
|
||||
override fun receive(stream: Stream) {
|
||||
messageId = stream.readString()
|
||||
// React Native читает: toPublicKey, messageId
|
||||
toPublicKey = stream.readString()
|
||||
messageId = stream.readString()
|
||||
}
|
||||
|
||||
override fun send(): Stream {
|
||||
val stream = Stream()
|
||||
stream.writeInt16(getPacketId())
|
||||
stream.writeString(messageId)
|
||||
// React Native пишет: toPublicKey, messageId
|
||||
stream.writeString(toPublicKey)
|
||||
stream.writeString(messageId)
|
||||
return stream
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ class Protocol(
|
||||
private val client = OkHttpClient.Builder()
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.pingInterval(30, TimeUnit.SECONDS) // Автоматический ping/pong для keep-alive
|
||||
.build()
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
@@ -59,8 +60,8 @@ class Protocol(
|
||||
private val _lastError = MutableStateFlow<String?>(null)
|
||||
val lastError: StateFlow<String?> = _lastError.asStateFlow()
|
||||
|
||||
// Packet waiters - callbacks for specific packet types
|
||||
private val packetWaiters = mutableMapOf<Int, MutableList<(Packet) -> Unit>>()
|
||||
// Packet waiters - callbacks for specific packet types (thread-safe)
|
||||
private val packetWaiters = java.util.concurrent.ConcurrentHashMap<Int, MutableList<(Packet) -> Unit>>()
|
||||
|
||||
// Packet queue for packets sent before handshake complete
|
||||
private val packetQueue = mutableListOf<Packet>()
|
||||
@@ -104,7 +105,7 @@ class Protocol(
|
||||
|
||||
/**
|
||||
* Start heartbeat to keep connection alive
|
||||
* Как в Архиве - отправляем text "heartbeat"
|
||||
* Как в Архиве - отправляем text "heartbeat" СРАЗУ и потом с интервалом
|
||||
*/
|
||||
private fun startHeartbeat(intervalSeconds: Int) {
|
||||
heartbeatJob?.cancel()
|
||||
@@ -113,18 +114,36 @@ class Protocol(
|
||||
log("💓 Starting heartbeat with interval: ${intervalSeconds}s (sending every ${intervalMs}ms)")
|
||||
|
||||
heartbeatJob = scope.launch {
|
||||
// ⚡ СРАЗУ отправляем первый heartbeat (как в Архиве)
|
||||
sendHeartbeat()
|
||||
|
||||
while (isActive) {
|
||||
delay(intervalMs)
|
||||
try {
|
||||
if (webSocket?.send("heartbeat") == true) {
|
||||
log("💓 Heartbeat sent")
|
||||
} else {
|
||||
log("💔 Heartbeat failed - socket closed or null")
|
||||
sendHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправка heartbeat с проверкой состояния
|
||||
*/
|
||||
private fun sendHeartbeat() {
|
||||
try {
|
||||
if (_state.value == ProtocolState.AUTHENTICATED) {
|
||||
val sent = webSocket?.send("heartbeat") ?: false
|
||||
if (sent) {
|
||||
log("💓 Heartbeat sent")
|
||||
} else {
|
||||
log("💔 Heartbeat failed - socket closed or null")
|
||||
// Триггерим reconnect если heartbeat не прошёл
|
||||
if (!isManuallyClosed) {
|
||||
log("🔄 Triggering reconnect due to failed heartbeat")
|
||||
handleDisconnect()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log("💔 Heartbeat error: ${e.message}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log("💔 Heartbeat error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,15 @@ object ProtocolManager {
|
||||
val messagePacket = packet as PacketMessage
|
||||
addLog("📩 Incoming message from ${messagePacket.fromPublicKey.take(16)}...")
|
||||
|
||||
// ⚡ ВАЖНО: Отправляем подтверждение доставки обратно серверу
|
||||
// Без этого сервер не будет отправлять следующие сообщения!
|
||||
val deliveryPacket = PacketDelivery().apply {
|
||||
messageId = messagePacket.messageId
|
||||
toPublicKey = messagePacket.fromPublicKey
|
||||
}
|
||||
send(deliveryPacket)
|
||||
addLog("✅ Sent delivery confirmation for message ${messagePacket.messageId.take(16)}...")
|
||||
|
||||
scope.launch {
|
||||
messageRepository?.handleIncomingMessage(messagePacket)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
@@ -907,7 +908,7 @@ fun ChatDetailScreen(
|
||||
}
|
||||
|
||||
// 🔥 SELECTION ACTION BAR - Reply/Forward (появляется при выборе сообщений)
|
||||
// Стеклянный стиль как у инпута
|
||||
// Стеклянный стиль как у инпута с блюром
|
||||
AnimatedVisibility(
|
||||
visible = isSelectionMode,
|
||||
enter = fadeIn(tween(200)) + slideInVertically(initialOffsetY = { it }),
|
||||
@@ -915,40 +916,46 @@ fun ChatDetailScreen(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
// Glass container
|
||||
// Glass container с эффектом блюра
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(
|
||||
if (isDarkTheme) Color(0xFF2A2A2A).copy(alpha = 0.85f)
|
||||
else Color(0xFFF2F3F5).copy(alpha = 0.92f)
|
||||
if (isDarkTheme) Color(0xFF1C1C1E).copy(alpha = 0.7f)
|
||||
else Color(0xFFF8F9FA).copy(alpha = 0.8f)
|
||||
)
|
||||
.border(
|
||||
width = 0.5.dp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.08f)
|
||||
else Color.Black.copy(alpha = 0.06f),
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
width = 1.dp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.12f)
|
||||
else Color.Black.copy(alpha = 0.08f),
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp, horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
.padding(vertical = 10.dp, horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Reply button - стеклянная кнопка
|
||||
// Reply button - стеклянная кнопка с блюром
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(
|
||||
if (isDarkTheme) Color.White.copy(alpha = 0.08f)
|
||||
else Color.Black.copy(alpha = 0.04f)
|
||||
if (isDarkTheme) Color.White.copy(alpha = 0.1f)
|
||||
else Color.Black.copy(alpha = 0.06f)
|
||||
)
|
||||
.border(
|
||||
width = 0.5.dp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.15f)
|
||||
else Color.Black.copy(alpha = 0.1f),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
)
|
||||
.clickable {
|
||||
val selectedMsgs = messages
|
||||
@@ -957,7 +964,7 @@ fun ChatDetailScreen(
|
||||
viewModel.setReplyMessages(selectedMsgs)
|
||||
selectedMessages = emptySet()
|
||||
}
|
||||
.padding(vertical = 12.dp),
|
||||
.padding(vertical = 14.dp, horizontal = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(
|
||||
@@ -975,19 +982,25 @@ fun ChatDetailScreen(
|
||||
"Reply",
|
||||
color = PrimaryBlue,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Forward button - стеклянная кнопка
|
||||
// Forward button - стеклянная кнопка с блюром
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(
|
||||
if (isDarkTheme) Color.White.copy(alpha = 0.08f)
|
||||
else Color.Black.copy(alpha = 0.04f)
|
||||
if (isDarkTheme) Color.White.copy(alpha = 0.1f)
|
||||
else Color.Black.copy(alpha = 0.06f)
|
||||
)
|
||||
.border(
|
||||
width = 0.5.dp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.15f)
|
||||
else Color.Black.copy(alpha = 0.1f),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
)
|
||||
.clickable {
|
||||
val selectedMsgs = messages
|
||||
@@ -996,7 +1009,7 @@ fun ChatDetailScreen(
|
||||
viewModel.setForwardMessages(selectedMsgs)
|
||||
selectedMessages = emptySet()
|
||||
}
|
||||
.padding(vertical = 12.dp),
|
||||
.padding(vertical = 14.dp, horizontal = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(
|
||||
@@ -1014,7 +1027,7 @@ fun ChatDetailScreen(
|
||||
"Forward",
|
||||
color = PrimaryBlue,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1283,19 +1296,9 @@ private fun MessageBubble(
|
||||
)
|
||||
if (message.isOutgoing) {
|
||||
Spacer(modifier = Modifier.width(3.dp))
|
||||
Icon(
|
||||
when (message.status) {
|
||||
MessageStatus.SENDING -> Icons.Default.Schedule
|
||||
MessageStatus.SENT -> Icons.Default.Done
|
||||
MessageStatus.DELIVERED -> Icons.Default.DoneAll
|
||||
MessageStatus.READ -> Icons.Default.DoneAll
|
||||
},
|
||||
contentDescription = null,
|
||||
tint =
|
||||
if (message.status == MessageStatus.READ)
|
||||
Color(0xFF4FC3F7) // Голубые галочки как в Telegram
|
||||
else timeColor,
|
||||
modifier = Modifier.size(16.dp)
|
||||
AnimatedMessageStatus(
|
||||
status = message.status,
|
||||
timeColor = timeColor
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1304,6 +1307,65 @@ private fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 Анимированный статус сообщения с плавными переходами
|
||||
*/
|
||||
@Composable
|
||||
private fun AnimatedMessageStatus(
|
||||
status: MessageStatus,
|
||||
timeColor: Color
|
||||
) {
|
||||
// Цвет с анимацией
|
||||
val targetColor = if (status == MessageStatus.READ) Color(0xFF4FC3F7) else timeColor
|
||||
val animatedColor by animateColorAsState(
|
||||
targetValue = targetColor,
|
||||
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing),
|
||||
label = "statusColor"
|
||||
)
|
||||
|
||||
// Анимация scale для эффекта "pop"
|
||||
var previousStatus by remember { mutableStateOf(status) }
|
||||
var shouldAnimate by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(status) {
|
||||
if (previousStatus != status) {
|
||||
shouldAnimate = true
|
||||
previousStatus = status
|
||||
}
|
||||
}
|
||||
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (shouldAnimate) 1.2f else 1f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessLow
|
||||
),
|
||||
finishedListener = { shouldAnimate = false },
|
||||
label = "statusScale"
|
||||
)
|
||||
|
||||
// Crossfade для плавной смены иконки
|
||||
Crossfade(
|
||||
targetState = status,
|
||||
animationSpec = tween(durationMillis = 200),
|
||||
label = "statusIcon"
|
||||
) { currentStatus ->
|
||||
Icon(
|
||||
imageVector = when (currentStatus) {
|
||||
MessageStatus.SENDING -> Icons.Default.Schedule
|
||||
MessageStatus.SENT -> Icons.Default.Done
|
||||
MessageStatus.DELIVERED -> Icons.Default.DoneAll
|
||||
MessageStatus.READ -> Icons.Default.DoneAll
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = animatedColor,
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.scale(scale)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 🚀 Разделитель даты с fade-in анимацией */
|
||||
@Composable
|
||||
private fun DateHeader(dateText: String, secondaryTextColor: Color) {
|
||||
|
||||
@@ -226,12 +226,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// Обновляем диалог
|
||||
saveDialog(decryptedText, packet.timestamp)
|
||||
|
||||
// Отправляем подтверждение доставки
|
||||
val deliveryPacket = PacketDelivery().apply {
|
||||
toPublicKey = packet.fromPublicKey
|
||||
messageId = packet.messageId
|
||||
// ⚠️ Delivery отправляется в ProtocolManager.setupPacketHandlers()
|
||||
// Не отправляем повторно чтобы избежать дублирования!
|
||||
|
||||
// 👁️ Сразу отправляем read receipt (как в Telegram - сообщения прочитаны если чат открыт)
|
||||
delay(100) // Небольшая задержка для естественности
|
||||
withContext(Dispatchers.Main) {
|
||||
sendReadReceipt(packet.messageId, packet.fromPublicKey)
|
||||
}
|
||||
ProtocolManager.send(deliveryPacket)
|
||||
|
||||
} catch (e: Exception) {
|
||||
ProtocolManager.addLog("❌ Error handling incoming message: ${e.message}")
|
||||
|
||||
@@ -16,17 +16,20 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.airbnb.lottie.compose.*
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
|
||||
/**
|
||||
* Компонент отображения результатов поиска пользователей Аналогичен результатам поиска в React
|
||||
* Native приложении
|
||||
* Компонент отображения результатов поиска пользователей
|
||||
* Минималистичный дизайн с плавными анимациями
|
||||
*/
|
||||
@Composable
|
||||
fun SearchResultsList(
|
||||
@@ -41,64 +44,122 @@ fun SearchResultsList(
|
||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
// Loading state
|
||||
AnimatedVisibility(
|
||||
visible = isSearching,
|
||||
enter = fadeIn(animationSpec = tween(durationMillis = 200)),
|
||||
exit = fadeOut(animationSpec = tween(durationMillis = 200))
|
||||
enter = fadeIn(animationSpec = tween(200)),
|
||||
exit = fadeOut(animationSpec = tween(150))
|
||||
) {
|
||||
// Индикатор загрузки в центре
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding(), // Поднимается при клавиатуре
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
color = if (isDarkTheme) Color(0xFF9E9E9E) else PrimaryBlue,
|
||||
strokeWidth = 3.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(text = "Searching...", fontSize = 14.sp, color = secondaryTextColor)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
color = PrimaryBlue,
|
||||
strokeWidth = 2.5.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Searching...",
|
||||
fontSize = 14.sp,
|
||||
color = secondaryTextColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state - поднимается при открытии клавиатуры
|
||||
AnimatedVisibility(
|
||||
visible = !isSearching && searchResults.isEmpty(),
|
||||
enter = fadeIn(animationSpec = tween(durationMillis = 300)),
|
||||
exit = fadeOut(animationSpec = tween(durationMillis = 200))
|
||||
enter = fadeIn(animationSpec = tween(300)),
|
||||
exit = fadeOut(animationSpec = tween(200))
|
||||
) {
|
||||
// Подсказка в центре экрана
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "Search by username or public key",
|
||||
fontSize = 15.sp,
|
||||
color = secondaryTextColor
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding(), // Поднимается при клавиатуре
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Lottie search animation
|
||||
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.search))
|
||||
val progress by animateLottieCompositionAsState(
|
||||
composition = composition,
|
||||
iterations = LottieConstants.IterateForever
|
||||
)
|
||||
|
||||
LottieAnimation(
|
||||
composition = composition,
|
||||
progress = { progress },
|
||||
modifier = Modifier.size(120.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Text(
|
||||
text = "Search for users",
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "Enter username or public key",
|
||||
fontSize = 14.sp,
|
||||
color = secondaryTextColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Results list with staggered animation
|
||||
AnimatedVisibility(
|
||||
visible = !isSearching && searchResults.isNotEmpty(),
|
||||
enter = fadeIn(animationSpec = tween(durationMillis = 300)),
|
||||
exit = fadeOut(animationSpec = tween(durationMillis = 200))
|
||||
enter = fadeIn(animationSpec = tween(250)),
|
||||
exit = fadeOut(animationSpec = tween(150))
|
||||
) {
|
||||
// Список результатов без серой плашки
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 4.dp)
|
||||
) {
|
||||
itemsIndexed(searchResults) { index, user ->
|
||||
SearchResultItem(
|
||||
user = user,
|
||||
isOwnAccount = user.publicKey == currentUserPublicKey,
|
||||
isDarkTheme = isDarkTheme,
|
||||
isLastItem = index == searchResults.size - 1,
|
||||
onClick = { onUserClick(user) }
|
||||
)
|
||||
// Staggered animation
|
||||
var isVisible by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(index * 40L)
|
||||
isVisible = true
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
enter = fadeIn(tween(200)) + slideInHorizontally(
|
||||
initialOffsetX = { it / 3 },
|
||||
animationSpec = tween(250, easing = FastOutSlowInEasing)
|
||||
),
|
||||
exit = fadeOut(tween(100))
|
||||
) {
|
||||
SearchResultItem(
|
||||
user = user,
|
||||
isOwnAccount = user.publicKey == currentUserPublicKey,
|
||||
isDarkTheme = isDarkTheme,
|
||||
isLastItem = index == searchResults.size - 1,
|
||||
onClick = { onUserClick(user) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Элемент результата поиска - пользователь */
|
||||
/** Элемент результата поиска - минималистичный дизайн */
|
||||
@Composable
|
||||
private fun SearchResultItem(
|
||||
user: SearchUser,
|
||||
@@ -109,32 +170,28 @@ private fun SearchResultItem(
|
||||
) {
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
|
||||
val dividerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8)
|
||||
|
||||
// Получаем цвета аватара - используем publicKey для консистентности
|
||||
val avatarColors =
|
||||
getAvatarColor(
|
||||
if (isOwnAccount) "SavedMessages" else user.publicKey,
|
||||
isDarkTheme
|
||||
)
|
||||
// Получаем цвета аватара
|
||||
val avatarColors = getAvatarColor(
|
||||
if (isOwnAccount) "SavedMessages" else user.publicKey,
|
||||
isDarkTheme
|
||||
)
|
||||
|
||||
Column {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Аватар
|
||||
// Avatar - clean and simple
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isOwnAccount) PrimaryBlue
|
||||
else avatarColors.backgroundColor
|
||||
),
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (isOwnAccount) PrimaryBlue else avatarColors.backgroundColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (isOwnAccount) {
|
||||
@@ -146,14 +203,13 @@ private fun SearchResultItem(
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text =
|
||||
if (user.title.isNotEmpty()) {
|
||||
text = if (user.title.isNotEmpty()) {
|
||||
getInitials(user.title)
|
||||
} else {
|
||||
user.publicKey.take(2).uppercase()
|
||||
},
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = avatarColors.textColor
|
||||
)
|
||||
}
|
||||
@@ -161,28 +217,23 @@ private fun SearchResultItem(
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// Информация о пользователе
|
||||
// User info
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
// Имя и значок верификации
|
||||
// Name and verification badge
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
if (isOwnAccount) {
|
||||
"Saved Messages"
|
||||
} else {
|
||||
user.title.ifEmpty { user.publicKey.take(10) }
|
||||
},
|
||||
text = if (isOwnAccount) "Saved Messages"
|
||||
else user.title.ifEmpty { user.publicKey.take(10) },
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
// Значок верификации
|
||||
if (!isOwnAccount && user.verified > 0) {
|
||||
VerifiedBadge(verified = user.verified, size = 16)
|
||||
}
|
||||
@@ -190,14 +241,10 @@ private fun SearchResultItem(
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
// Юзернейм или публичный ключ
|
||||
// Username
|
||||
Text(
|
||||
text =
|
||||
if (isOwnAccount) {
|
||||
"Notes"
|
||||
} else {
|
||||
"@${user.username.ifEmpty { user.publicKey.take(10) + "..." }}"
|
||||
},
|
||||
text = if (isOwnAccount) "Notes"
|
||||
else "@${user.username.ifEmpty { user.publicKey.take(10) + "..." }}",
|
||||
fontSize = 14.sp,
|
||||
color = secondaryTextColor,
|
||||
maxLines = 1,
|
||||
@@ -206,12 +253,12 @@ private fun SearchResultItem(
|
||||
}
|
||||
}
|
||||
|
||||
// Разделитель между элементами
|
||||
// Simple divider
|
||||
if (!isLastItem) {
|
||||
Divider(
|
||||
modifier = Modifier.padding(start = 80.dp),
|
||||
color = dividerColor,
|
||||
thickness = 0.5.dp
|
||||
modifier = Modifier.padding(start = 76.dp),
|
||||
color = dividerColor,
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.rosetta.messenger.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.airbnb.lottie.compose.*
|
||||
|
||||
/**
|
||||
* Компонент для отображения анимированных эмодзи Telegram в формате TGS
|
||||
*
|
||||
* TGS - это Lottie анимации (JSON), сжатые через gzip.
|
||||
* Библиотека Lottie автоматически распаковывает и проигрывает их.
|
||||
*
|
||||
* @param tgsFileName Имя файла из папки assets/emoji (например: "5431456783947234.tgs")
|
||||
* @param modifier Модификатор для кастомизации размера и других параметров
|
||||
* @param size Размер эмодзи (по умолчанию 32.dp)
|
||||
* @param iterations Количество повторений анимации (LottieConstants.IterateForever для бесконечного)
|
||||
* @param speed Скорость воспроизведения анимации (1.0f = нормальная скорость)
|
||||
*/
|
||||
@Composable
|
||||
fun TelegramAnimatedEmoji(
|
||||
tgsFileName: String,
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 32.dp,
|
||||
iterations: Int = LottieConstants.IterateForever,
|
||||
speed: Float = 1.0f
|
||||
) {
|
||||
// Путь к файлу в assets/emoji/
|
||||
val animationPath = "emoji/$tgsFileName"
|
||||
|
||||
// Загружаем анимацию из assets
|
||||
val composition by rememberLottieComposition(
|
||||
LottieCompositionSpec.Asset(animationPath)
|
||||
)
|
||||
|
||||
// Настраиваем воспроизведение
|
||||
val progress by animateLottieCompositionAsState(
|
||||
composition = composition,
|
||||
iterations = iterations,
|
||||
speed = speed,
|
||||
restartOnPlay = true
|
||||
)
|
||||
|
||||
// Отображаем Lottie анимацию
|
||||
LottieAnimation(
|
||||
composition = composition,
|
||||
progress = { progress },
|
||||
modifier = modifier.size(size)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Упрощенный вариант для быстрого использования с ID документа
|
||||
*
|
||||
* @param documentId ID документа из Telegram (например: 5431456783947234)
|
||||
*/
|
||||
@Composable
|
||||
fun TelegramAnimatedEmojiById(
|
||||
documentId: Long,
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 32.dp
|
||||
) {
|
||||
TelegramAnimatedEmoji(
|
||||
tgsFileName = "$documentId.tgs",
|
||||
modifier = modifier,
|
||||
size = size
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Вариант с возможностью остановки/запуска анимации
|
||||
*
|
||||
* @param isPlaying Управление воспроизведением анимации
|
||||
*/
|
||||
@Composable
|
||||
fun TelegramAnimatedEmojiPlayable(
|
||||
tgsFileName: String,
|
||||
isPlaying: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 32.dp,
|
||||
speed: Float = 1.0f
|
||||
) {
|
||||
val animationPath = "emoji/$tgsFileName"
|
||||
|
||||
val composition by rememberLottieComposition(
|
||||
LottieCompositionSpec.Asset(animationPath)
|
||||
)
|
||||
|
||||
val progress by animateLottieCompositionAsState(
|
||||
composition = composition,
|
||||
isPlaying = isPlaying,
|
||||
iterations = LottieConstants.IterateForever,
|
||||
speed = speed,
|
||||
restartOnPlay = true
|
||||
)
|
||||
|
||||
LottieAnimation(
|
||||
composition = composition,
|
||||
progress = { progress },
|
||||
modifier = modifier.size(size)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user