feat: Enhance chat UI with group invite handling and new download indicator
- Added support for standalone group invites in MessageBubble component. - Improved bubble padding and width handling for group invites. - Refactored MessageBubble to streamline background and border logic. - Introduced AnimatedDownloadIndicator for a more engaging download experience. - Created ThemeWallpapers data structure to manage chat wallpapers. - Implemented WallpaperSelectorRow and WallpaperSelectorItem for theme customization. - Updated ThemeScreen to allow wallpaper selection and preview. - Added new drawable resources for download and search icons.
This commit is contained in:
@@ -23,6 +23,7 @@ import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
@@ -51,6 +52,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
@@ -91,6 +93,7 @@ import com.rosetta.messenger.ui.chats.utils.*
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import com.rosetta.messenger.ui.settings.ThemeWallpapers
|
||||
import com.rosetta.messenger.ui.utils.SystemBarsStyleUtils
|
||||
import com.rosetta.messenger.utils.MediaUtils
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -121,6 +124,7 @@ fun ChatDetailScreen(
|
||||
currentUserName: String = "",
|
||||
totalUnreadFromOthers: Int = 0,
|
||||
isDarkTheme: Boolean,
|
||||
chatWallpaperId: String = "",
|
||||
avatarRepository: AvatarRepository? = null,
|
||||
onImageViewerChanged: (Boolean) -> Unit = {}
|
||||
) {
|
||||
@@ -144,6 +148,7 @@ fun ChatDetailScreen(
|
||||
|
||||
// UI Theme
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||
val chatWallpaperResId = remember(chatWallpaperId) { ThemeWallpapers.drawableResOrNull(chatWallpaperId) }
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
|
||||
val headerIconColor = Color.White
|
||||
@@ -1814,14 +1819,29 @@ fun ChatDetailScreen(
|
||||
) { paddingValues ->
|
||||
// 🔥 Box wrapper для overlay (MediaPicker над клавиатурой)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// 🔥 Column структура - список сжимается когда клавиатура
|
||||
// открывается
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.background(backgroundColor)
|
||||
) {
|
||||
// Keep wallpaper on a fixed full-screen layer so it doesn't rescale
|
||||
// when content paddings (bottom bar/IME) change.
|
||||
if (chatWallpaperResId != null) {
|
||||
Image(
|
||||
painter = painterResource(id = chatWallpaperResId),
|
||||
contentDescription = "Chat wallpaper",
|
||||
modifier = Modifier.matchParentSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.matchParentSize()
|
||||
.background(backgroundColor)
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
|
||||
// 🔥 Column структура - список сжимается когда клавиатура
|
||||
// открывается
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
// Список сообщений - занимает всё доступное место
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
// Плавная анимация bottom padding при входе/выходе из selection mode
|
||||
@@ -2495,27 +2515,6 @@ fun ChatDetailScreen(
|
||||
} // Закрытие Box wrapper для Scaffold content
|
||||
} // Закрытие Box
|
||||
|
||||
// 📸 Image Viewer Overlay with Telegram-style shared element animation
|
||||
if (showImageViewer && imageViewerImages.isNotEmpty()) {
|
||||
ImageViewerScreen(
|
||||
images = imageViewerImages,
|
||||
initialIndex = imageViewerInitialIndex,
|
||||
privateKey = currentUserPrivateKey,
|
||||
onDismiss = {
|
||||
showImageViewer = false
|
||||
imageViewerSourceBounds = null
|
||||
imageViewerImages = emptyList()
|
||||
onImageViewerChanged(false)
|
||||
},
|
||||
onClosingStart = {
|
||||
// Сразу сбрасываем status bar при начале закрытия (до анимации)
|
||||
SystemBarsStyleUtils.applyChatStatusBar(window, view, isDarkTheme)
|
||||
},
|
||||
isDarkTheme = isDarkTheme,
|
||||
sourceBounds = imageViewerSourceBounds
|
||||
)
|
||||
}
|
||||
|
||||
// Диалог подтверждения удаления чата
|
||||
if (showDeleteConfirm) {
|
||||
val isLeaveGroupDialog = user.publicKey.startsWith("#group:")
|
||||
@@ -2773,7 +2772,30 @@ fun ChatDetailScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// 📷 In-App Camera (без системного превью!)
|
||||
} // Закрытие Scaffold content lambda
|
||||
|
||||
// <20> Image Viewer Overlay — FULLSCREEN поверх Scaffold
|
||||
if (showImageViewer && imageViewerImages.isNotEmpty()) {
|
||||
ImageViewerScreen(
|
||||
images = imageViewerImages,
|
||||
initialIndex = imageViewerInitialIndex,
|
||||
privateKey = currentUserPrivateKey,
|
||||
onDismiss = {
|
||||
showImageViewer = false
|
||||
imageViewerSourceBounds = null
|
||||
imageViewerImages = emptyList()
|
||||
onImageViewerChanged(false)
|
||||
},
|
||||
onClosingStart = {
|
||||
// Сразу сбрасываем status bar при начале закрытия (до анимации)
|
||||
SystemBarsStyleUtils.applyChatStatusBar(window, view, isDarkTheme)
|
||||
},
|
||||
isDarkTheme = isDarkTheme,
|
||||
sourceBounds = imageViewerSourceBounds
|
||||
)
|
||||
}
|
||||
|
||||
// <20>📷 In-App Camera — FULLSCREEN поверх Scaffold (вне content lambda)
|
||||
if (showInAppCamera) {
|
||||
InAppCameraScreen(
|
||||
onDismiss = { showInAppCamera = false },
|
||||
@@ -2835,5 +2857,5 @@ fun ChatDetailScreen(
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
} // Закрытие outer Box
|
||||
}
|
||||
|
||||
@@ -442,7 +442,11 @@ fun ChatsListScreen(
|
||||
val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
|
||||
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
|
||||
|
||||
// 🔥 Пользователи, которые сейчас печатают
|
||||
// <EFBFBD> Active downloads tracking (for header indicator)
|
||||
val activeDownloads by com.rosetta.messenger.network.TransportManager.downloading.collectAsState()
|
||||
val hasActiveDownloads = activeDownloads.isNotEmpty()
|
||||
|
||||
// <20>🔥 Пользователи, которые сейчас печатают
|
||||
val typingUsers by ProtocolManager.typingUsers.collectAsState()
|
||||
|
||||
// Load dialogs when account is available
|
||||
@@ -1593,6 +1597,16 @@ fun ChatsListScreen(
|
||||
},
|
||||
actions = {
|
||||
if (!showRequestsScreen) {
|
||||
// 📥 Animated download indicator (Telegram-style)
|
||||
Box(
|
||||
modifier = androidx.compose.ui.Modifier.size(48.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
com.rosetta.messenger.ui.components.AnimatedDownloadIndicator(
|
||||
isActive = hasActiveDownloads,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (protocolState ==
|
||||
|
||||
@@ -21,10 +21,7 @@ import compose.icons.TablerIcons
|
||||
import compose.icons.tablericons.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Full-screen connection logs viewer.
|
||||
* Shows all protocol/WebSocket logs from ProtocolManager.debugLogs.
|
||||
*/
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConnectionLogsScreen(
|
||||
@@ -43,7 +40,6 @@ fun ConnectionLogsScreen(
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
LaunchedEffect(logs.size) {
|
||||
if (logs.isNotEmpty()) {
|
||||
listState.animateScrollToItem(logs.size - 1)
|
||||
@@ -56,7 +52,6 @@ fun ConnectionLogsScreen(
|
||||
.background(bgColor)
|
||||
.statusBarsPadding()
|
||||
) {
|
||||
// Header
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -83,7 +78,6 @@ fun ConnectionLogsScreen(
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
// Clear button
|
||||
IconButton(onClick = { ProtocolManager.clearLogs() }) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.Trash,
|
||||
@@ -93,7 +87,6 @@ fun ConnectionLogsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Scroll to bottom
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
if (logs.isNotEmpty()) listState.animateScrollToItem(logs.size - 1)
|
||||
@@ -109,7 +102,6 @@ fun ConnectionLogsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Status bar
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -159,7 +151,6 @@ fun ConnectionLogsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Logs list
|
||||
if (logs.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
||||
@@ -3,6 +3,9 @@ package com.rosetta.messenger.ui.chats
|
||||
import android.app.Activity
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
@@ -25,10 +28,13 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
@@ -79,6 +85,7 @@ import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
@@ -214,8 +221,6 @@ fun GroupInfoScreen(
|
||||
onGroupLeft: () -> Unit = {},
|
||||
onSwipeBackEnabledChanged: (Boolean) -> Unit = {}
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val view = LocalView.current
|
||||
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
|
||||
@@ -276,7 +281,7 @@ fun GroupInfoScreen(
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var showLeaveConfirm by remember { mutableStateOf(false) }
|
||||
var isLeaving by remember { mutableStateOf(false) }
|
||||
var showEncryptionDialog by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
|
||||
var showEncryptionPage by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
|
||||
var encryptionKey by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
|
||||
var encryptionKeyLoading by remember { mutableStateOf(false) }
|
||||
var membersLoading by remember { mutableStateOf(false) }
|
||||
@@ -451,9 +456,23 @@ fun GroupInfoScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val onlineCount by remember(members, memberInfoByKey) {
|
||||
val normalizedCurrentUserKey = remember(currentUserPublicKey) { currentUserPublicKey.trim() }
|
||||
|
||||
val onlineCount by remember(members, memberInfoByKey, normalizedCurrentUserKey) {
|
||||
derivedStateOf {
|
||||
members.count { key -> (memberInfoByKey[key]?.online ?: 0) > 0 }
|
||||
if (members.isEmpty()) {
|
||||
0
|
||||
} else {
|
||||
val selfOnline = if (normalizedCurrentUserKey.isNotBlank()) 1 else 0
|
||||
val othersOnline =
|
||||
members.count { key ->
|
||||
val isCurrentUser =
|
||||
normalizedCurrentUserKey.isNotBlank() &&
|
||||
key.trim().equals(normalizedCurrentUserKey, ignoreCase = true)
|
||||
!isCurrentUser && (memberInfoByKey[key]?.online ?: 0) > 0
|
||||
}
|
||||
selfOnline + othersOnline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,7 +517,6 @@ fun GroupInfoScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
val normalizedCurrentUserKey = remember(currentUserPublicKey) { currentUserPublicKey.trim() }
|
||||
val currentUserIsAdmin by remember(members, normalizedCurrentUserKey) {
|
||||
derivedStateOf {
|
||||
members.firstOrNull()?.trim()?.equals(normalizedCurrentUserKey, ignoreCase = true) == true
|
||||
@@ -508,6 +526,14 @@ fun GroupInfoScreen(
|
||||
var memberToKick by remember(dialogPublicKey) { mutableStateOf<GroupMemberUi?>(null) }
|
||||
var isKickingMember by remember(dialogPublicKey) { mutableStateOf(false) }
|
||||
|
||||
BackHandler {
|
||||
if (showEncryptionPage) {
|
||||
showEncryptionPage = false
|
||||
} else {
|
||||
onBack()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedTab) {
|
||||
if (selectedTab != GroupInfoTab.MEMBERS) {
|
||||
swipedMemberKey = null
|
||||
@@ -684,7 +710,7 @@ fun GroupInfoScreen(
|
||||
return@launch
|
||||
}
|
||||
encryptionKey = key
|
||||
showEncryptionDialog = true
|
||||
showEncryptionPage = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1194,103 +1220,26 @@ fun GroupInfoScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (showEncryptionDialog) {
|
||||
AnimatedVisibility(
|
||||
visible = showEncryptionPage,
|
||||
enter = fadeIn(animationSpec = tween(durationMillis = 260)),
|
||||
exit = fadeOut(animationSpec = tween(durationMillis = 200)),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
val displayLines = remember(encryptionKey) { encodeGroupKeyForDisplay(encryptionKey) }
|
||||
val keyImagePalette = if (isDarkTheme) {
|
||||
listOf(
|
||||
Color(0xFF2B4F78),
|
||||
Color(0xFF2F5F90),
|
||||
Color(0xFF3D74A8),
|
||||
Color(0xFF4E89BE),
|
||||
Color(0xFF64A0D6)
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Color(0xFFD5E8FF),
|
||||
Color(0xFFBBD9FF),
|
||||
Color(0xFFA1CAFF),
|
||||
Color(0xFF87BAFF),
|
||||
Color(0xFF6EA9F4)
|
||||
)
|
||||
}
|
||||
val keyCardColor = if (isDarkTheme) Color(0xFF1F1F22) else Color(0xFFF7F9FC)
|
||||
val keyCodeColor = if (isDarkTheme) Color(0xFFC7D6EA) else Color(0xFF34495E)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { showEncryptionDialog = false },
|
||||
containerColor = cardColor,
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
title = {
|
||||
Text(
|
||||
text = "Encryption key",
|
||||
color = primaryText,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = keyCardColor,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 14.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
DesktopStyleKeyImage(
|
||||
keyRender = encryptionKey,
|
||||
size = 180.dp,
|
||||
radius = 14.dp,
|
||||
palette = keyImagePalette
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
SelectionContainer {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = sectionColor,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
|
||||
if (displayLines.isNotEmpty()) {
|
||||
displayLines.forEach { line ->
|
||||
Text(
|
||||
text = line,
|
||||
color = keyCodeColor,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Text(
|
||||
text = "This key encrypts and decrypts group messages.",
|
||||
color = secondaryText,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(encryptionKey))
|
||||
Toast.makeText(context, "Encryption key copied", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
) {
|
||||
Text("Copy", color = accentColor)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showEncryptionDialog = false }) {
|
||||
Text("Close", color = primaryText)
|
||||
}
|
||||
GroupEncryptionKeyPage(
|
||||
encryptionKey = encryptionKey,
|
||||
displayLines = displayLines,
|
||||
peerTitle = groupTitle,
|
||||
isDarkTheme = isDarkTheme,
|
||||
topSurfaceColor = topSurfaceColor,
|
||||
backgroundColor = backgroundColor,
|
||||
secondaryText = secondaryText,
|
||||
accentColor = accentColor,
|
||||
onBack = { showEncryptionPage = false },
|
||||
onCopy = {
|
||||
clipboardManager.setText(AnnotatedString(encryptionKey))
|
||||
Toast.makeText(context, "Encryption key copied", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1401,6 +1350,162 @@ private fun DesktopStyleKeyImage(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupEncryptionKeyPage(
|
||||
encryptionKey: String,
|
||||
displayLines: List<String>,
|
||||
peerTitle: String,
|
||||
isDarkTheme: Boolean,
|
||||
topSurfaceColor: Color,
|
||||
backgroundColor: Color,
|
||||
secondaryText: Color,
|
||||
accentColor: Color,
|
||||
onBack: () -> Unit,
|
||||
onCopy: () -> Unit
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val imageSize = (screenWidth - 80.dp).coerceIn(220.dp, 340.dp)
|
||||
val keyImagePalette = if (isDarkTheme) {
|
||||
listOf(
|
||||
Color(0xFF2B4F78),
|
||||
Color(0xFF2F5F90),
|
||||
Color(0xFF3D74A8),
|
||||
Color(0xFF4E89BE),
|
||||
Color(0xFF64A0D6)
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Color(0xFFD5E8FF),
|
||||
Color(0xFFBBD9FF),
|
||||
Color(0xFFA1CAFF),
|
||||
Color(0xFF87BAFF),
|
||||
Color(0xFF6EA9F4)
|
||||
)
|
||||
}
|
||||
val keyPanelColor = if (isDarkTheme) Color(0xFF202227) else Color(0xFFFFFFFF)
|
||||
val keyCodeColor = if (isDarkTheme) Color(0xFFC7D6EA) else Color(0xFF34495E)
|
||||
val detailsPanelColor = if (isDarkTheme) Color(0xFF1B1D22) else Color(0xFFF7F9FC)
|
||||
val safePeerTitle = peerTitle.ifBlank { "this group" }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(backgroundColor)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(topSurfaceColor)
|
||||
.padding(horizontal = 4.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Encryption Key",
|
||||
color = Color.White,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = onCopy) {
|
||||
Text(
|
||||
text = "Copy",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.widthIn(max = 420.dp),
|
||||
color = keyPanelColor,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
DesktopStyleKeyImage(
|
||||
keyRender = encryptionKey,
|
||||
size = imageSize,
|
||||
radius = 0.dp,
|
||||
palette = keyImagePalette
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = detailsPanelColor,
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
SelectionContainer {
|
||||
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
|
||||
displayLines.forEach { line ->
|
||||
Text(
|
||||
text = line,
|
||||
color = keyCodeColor,
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "This image and text were derived from the encryption key for this group with $safePeerTitle.",
|
||||
color = secondaryText,
|
||||
fontSize = 15.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 21.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Text(
|
||||
text = "If they look the same on $safePeerTitle's device, end-to-end encryption is guaranteed.",
|
||||
color = secondaryText,
|
||||
fontSize = 15.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 21.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
TextButton(onClick = { uriHandler.openUri("https://rosetta.im/") }) {
|
||||
Text(
|
||||
text = "Learn more at rosetta.im",
|
||||
color = accentColor,
|
||||
fontSize = 15.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupActionButton(
|
||||
modifier: Modifier = Modifier,
|
||||
|
||||
@@ -226,11 +226,13 @@ fun GroupSetupScreen(
|
||||
val density = LocalDensity.current
|
||||
val imeBottomPx = WindowInsets.ime.getBottom(density)
|
||||
val imeBottomDp = with(density) { imeBottomPx.toDp() }
|
||||
val keyboardOrEmojiHeight =
|
||||
if (coordinator.isEmojiBoxVisible) coordinator.emojiHeight else imeBottomDp
|
||||
val fabBottomPadding =
|
||||
if (keyboardOrEmojiHeight > 0.dp) {
|
||||
keyboardOrEmojiHeight + 14.dp
|
||||
if (coordinator.isEmojiBoxVisible) {
|
||||
// Emoji panel height is already reserved by Scaffold bottomBar.
|
||||
14.dp
|
||||
} else if (imeBottomDp > 0.dp) {
|
||||
// System keyboard is not part of Scaffold content padding.
|
||||
imeBottomDp + 14.dp
|
||||
} else {
|
||||
18.dp
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1433,7 +1433,6 @@ fun FileAttachment(
|
||||
messageStatus: MessageStatus = MessageStatus.READ
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
|
||||
var downloadProgress by remember { mutableStateOf(0f) }
|
||||
|
||||
@@ -1465,7 +1464,30 @@ fun FileAttachment(
|
||||
val downloadsDir = remember { File(context.filesDir, "rosetta_downloads").apply { mkdirs() } }
|
||||
val savedFile = remember(fileName) { File(downloadsDir, fileName) }
|
||||
|
||||
// 📥 Подписываемся на глобальный FileDownloadManager для live-прогресса
|
||||
val managerState by com.rosetta.messenger.network.FileDownloadManager
|
||||
.progressOf(attachment.id)
|
||||
.collectAsState(initial = null)
|
||||
|
||||
// Синхронизируем локальный UI с глобальным менеджером
|
||||
LaunchedEffect(managerState) {
|
||||
val state = managerState ?: return@LaunchedEffect
|
||||
downloadProgress = state.progress
|
||||
downloadStatus = when (state.status) {
|
||||
com.rosetta.messenger.network.FileDownloadStatus.QUEUED,
|
||||
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> DownloadStatus.DOWNLOADING
|
||||
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING -> DownloadStatus.DECRYPTING
|
||||
com.rosetta.messenger.network.FileDownloadStatus.DONE -> DownloadStatus.DOWNLOADED
|
||||
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> DownloadStatus.ERROR
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(attachment.id) {
|
||||
// Если менеджер уже качает этот файл — подхватим состояние оттуда
|
||||
if (com.rosetta.messenger.network.FileDownloadManager.isDownloading(attachment.id)) {
|
||||
downloadStatus = DownloadStatus.DOWNLOADING
|
||||
return@LaunchedEffect
|
||||
}
|
||||
downloadStatus = if (isDownloadTag(preview)) {
|
||||
// Проверяем, был ли файл уже скачан ранее
|
||||
if (savedFile.exists()) DownloadStatus.DOWNLOADED
|
||||
@@ -1507,76 +1529,20 @@ fun FileAttachment(
|
||||
}
|
||||
}
|
||||
|
||||
// 📥 Запуск скачивания через глобальный FileDownloadManager
|
||||
val download: () -> Unit = {
|
||||
if (downloadTag.isNotEmpty()) {
|
||||
scope.launch {
|
||||
try {
|
||||
downloadStatus = DownloadStatus.DOWNLOADING
|
||||
|
||||
// Streaming: скачиваем во temp file, не в память
|
||||
val success =
|
||||
if (isGroupStoredKey(chachaKey)) {
|
||||
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
|
||||
downloadProgress = 0.5f
|
||||
downloadStatus = DownloadStatus.DECRYPTING
|
||||
|
||||
val groupPassword = decodeGroupPassword(chachaKey, privateKey)
|
||||
if (groupPassword.isNullOrBlank()) {
|
||||
false
|
||||
} else {
|
||||
val decrypted = CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
|
||||
val bytes = decrypted?.let { decodeBase64Payload(it) }
|
||||
if (bytes != null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
savedFile.parentFile?.mkdirs()
|
||||
savedFile.writeBytes(bytes)
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Streaming: скачиваем во temp file, не в память
|
||||
val tempFile = TransportManager.downloadFileRaw(attachment.id, downloadTag)
|
||||
downloadProgress = 0.5f
|
||||
|
||||
downloadStatus = DownloadStatus.DECRYPTING
|
||||
|
||||
val decryptedKeyAndNonce =
|
||||
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
||||
downloadProgress = 0.6f
|
||||
|
||||
// Streaming decrypt: tempFile → AES → inflate → base64 → savedFile
|
||||
// Пиковое потребление памяти ~128KB вместо ~200MB
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
MessageCrypto.decryptAttachmentFileStreaming(
|
||||
tempFile,
|
||||
decryptedKeyAndNonce,
|
||||
savedFile
|
||||
)
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
downloadProgress = 0.95f
|
||||
|
||||
if (success) {
|
||||
downloadProgress = 1f
|
||||
downloadStatus = DownloadStatus.DOWNLOADED
|
||||
} else {
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
} catch (_: OutOfMemoryError) {
|
||||
System.gc()
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
}
|
||||
}
|
||||
downloadStatus = DownloadStatus.DOWNLOADING
|
||||
downloadProgress = 0f
|
||||
com.rosetta.messenger.network.FileDownloadManager.download(
|
||||
context = context,
|
||||
attachmentId = attachment.id,
|
||||
downloadTag = downloadTag,
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
fileName = fileName,
|
||||
savedFile = savedFile
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1623,7 +1589,9 @@ fun FileAttachment(
|
||||
) {
|
||||
when (downloadStatus) {
|
||||
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
|
||||
// Determinate progress like Telegram
|
||||
CircularProgressIndicator(
|
||||
progress = downloadProgress.coerceIn(0f, 1f),
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
@@ -1693,10 +1661,14 @@ fun FileAttachment(
|
||||
|
||||
when (downloadStatus) {
|
||||
DownloadStatus.DOWNLOADING -> {
|
||||
AnimatedDotsText(
|
||||
baseText = "Downloading",
|
||||
color = statusColor,
|
||||
fontSize = 12.sp
|
||||
// Telegram-style: "1.2 MB / 5.4 MB"
|
||||
// CDN download maps to progress 0..0.8
|
||||
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
|
||||
val downloadedBytes = (cdnFraction * fileSize).toLong()
|
||||
Text(
|
||||
text = "${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
|
||||
fontSize = 12.sp,
|
||||
color = statusColor
|
||||
)
|
||||
}
|
||||
DownloadStatus.DECRYPTING -> {
|
||||
|
||||
@@ -592,11 +592,19 @@ fun MessageBubble(
|
||||
.IMAGE
|
||||
}
|
||||
|
||||
val isStandaloneGroupInvite =
|
||||
message.attachments.isEmpty() &&
|
||||
message.replyData == null &&
|
||||
message.forwardedMessages.isEmpty() &&
|
||||
message.text.isNotBlank() &&
|
||||
isGroupInviteCode(message.text)
|
||||
|
||||
// Для сообщений только с фото - минимальный padding и тонкий border
|
||||
// Для фото + caption - padding только внизу для текста
|
||||
val bubblePadding =
|
||||
when {
|
||||
isSafeSystemMessage -> PaddingValues(0.dp)
|
||||
isStandaloneGroupInvite -> PaddingValues(0.dp)
|
||||
hasOnlyMedia -> PaddingValues(0.dp)
|
||||
hasImageWithCaption -> PaddingValues(0.dp)
|
||||
else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
|
||||
@@ -676,6 +684,8 @@ fun MessageBubble(
|
||||
val bubbleWidthModifier =
|
||||
if (isSafeSystemMessage) {
|
||||
Modifier.widthIn(min = 220.dp, max = 320.dp)
|
||||
} else if (isStandaloneGroupInvite) {
|
||||
Modifier.widthIn(min = 220.dp, max = 320.dp)
|
||||
} else if (hasImageWithCaption || hasOnlyMedia) {
|
||||
Modifier.width(
|
||||
photoWidth
|
||||
@@ -703,46 +713,52 @@ fun MessageBubble(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick
|
||||
)
|
||||
.clip(bubbleShape)
|
||||
.then(
|
||||
if (hasOnlyMedia) {
|
||||
Modifier.border(
|
||||
width = bubbleBorderWidth,
|
||||
color =
|
||||
if (message.isOutgoing
|
||||
) {
|
||||
Color.White
|
||||
.copy(
|
||||
alpha =
|
||||
0.15f
|
||||
)
|
||||
} else {
|
||||
if (isDarkTheme
|
||||
)
|
||||
Color.White
|
||||
.copy(
|
||||
alpha =
|
||||
0.1f
|
||||
)
|
||||
else
|
||||
Color.Black
|
||||
.copy(
|
||||
alpha =
|
||||
0.08f
|
||||
)
|
||||
},
|
||||
shape = bubbleShape
|
||||
)
|
||||
} else if (isSafeSystemMessage) {
|
||||
Modifier.background(
|
||||
if (isDarkTheme) Color(0xFF2A2A2D)
|
||||
else Color(0xFFF0F0F4)
|
||||
)
|
||||
if (isStandaloneGroupInvite) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.background(bubbleColor)
|
||||
Modifier.clip(bubbleShape)
|
||||
.then(
|
||||
if (hasOnlyMedia) {
|
||||
Modifier.border(
|
||||
width = bubbleBorderWidth,
|
||||
color =
|
||||
if (message.isOutgoing
|
||||
) {
|
||||
Color.White
|
||||
.copy(
|
||||
alpha =
|
||||
0.15f
|
||||
)
|
||||
} else {
|
||||
if (isDarkTheme
|
||||
)
|
||||
Color.White
|
||||
.copy(
|
||||
alpha =
|
||||
0.1f
|
||||
)
|
||||
else
|
||||
Color.Black
|
||||
.copy(
|
||||
alpha =
|
||||
0.08f
|
||||
)
|
||||
},
|
||||
shape = bubbleShape
|
||||
)
|
||||
} else if (isSafeSystemMessage) {
|
||||
Modifier.background(
|
||||
if (isDarkTheme) Color(0xFF2A2A2D)
|
||||
else Color(0xFFF0F0F4)
|
||||
)
|
||||
} else {
|
||||
Modifier.background(bubbleColor)
|
||||
}
|
||||
)
|
||||
.padding(bubblePadding)
|
||||
}
|
||||
)
|
||||
.padding(bubblePadding)
|
||||
) {
|
||||
if (isSafeSystemMessage) {
|
||||
SafeSystemMessageCard(
|
||||
@@ -1045,35 +1061,12 @@ fun MessageBubble(
|
||||
accountPublicKey = currentUserPublicKey,
|
||||
accountPrivateKey = privateKey,
|
||||
actionsEnabled = !isSelectionMode,
|
||||
timestamp = message.timestamp,
|
||||
messageStatus = displayStatus,
|
||||
onRetry = onRetry,
|
||||
onDelete = onDelete,
|
||||
onOpenGroup = onGroupInviteOpen
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = timeFormat.format(message.timestamp),
|
||||
color = timeColor,
|
||||
fontSize = 11.sp,
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
||||
)
|
||||
if (message.isOutgoing) {
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
AnimatedMessageStatus(
|
||||
status = displayStatus,
|
||||
timeColor = statusColor,
|
||||
isDarkTheme = isDarkTheme,
|
||||
isOutgoing = message.isOutgoing,
|
||||
timestamp = message.timestamp.time,
|
||||
onRetry = onRetry,
|
||||
onDelete = onDelete
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Telegram-style: текст + время с автоматическим
|
||||
// переносом
|
||||
@@ -1275,6 +1268,10 @@ private fun GroupInviteInlineCard(
|
||||
accountPublicKey: String,
|
||||
accountPrivateKey: String,
|
||||
actionsEnabled: Boolean,
|
||||
timestamp: Date,
|
||||
messageStatus: MessageStatus,
|
||||
onRetry: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onOpenGroup: (SearchUser) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -1352,19 +1349,19 @@ private fun GroupInviteInlineCard(
|
||||
|
||||
val cardBackground =
|
||||
if (isOutgoing) {
|
||||
Color.White.copy(alpha = 0.16f)
|
||||
PrimaryBlue
|
||||
} else if (isDarkTheme) {
|
||||
Color.White.copy(alpha = 0.06f)
|
||||
Color(0xFF222326)
|
||||
} else {
|
||||
Color.Black.copy(alpha = 0.03f)
|
||||
Color(0xFFF5F7FA)
|
||||
}
|
||||
val cardBorder =
|
||||
if (isOutgoing) {
|
||||
Color.White.copy(alpha = 0.22f)
|
||||
Color.White.copy(alpha = 0.24f)
|
||||
} else if (isDarkTheme) {
|
||||
Color.White.copy(alpha = 0.12f)
|
||||
Color.White.copy(alpha = 0.1f)
|
||||
} else {
|
||||
Color.Black.copy(alpha = 0.08f)
|
||||
Color.Black.copy(alpha = 0.07f)
|
||||
}
|
||||
val titleColor =
|
||||
if (isOutgoing) Color.White
|
||||
@@ -1374,6 +1371,12 @@ private fun GroupInviteInlineCard(
|
||||
if (isOutgoing) Color.White.copy(alpha = 0.82f)
|
||||
else if (isDarkTheme) Color(0xFFA9AFBA)
|
||||
else Color(0xFF70757F)
|
||||
val timeColor =
|
||||
if (isOutgoing) Color.White.copy(alpha = 0.74f)
|
||||
else if (isDarkTheme) Color(0xFF8E8E93)
|
||||
else Color(0xFF666666)
|
||||
val statusColor = if (isOutgoing) Color.White else timeColor
|
||||
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
||||
|
||||
val accentColor =
|
||||
when (status) {
|
||||
@@ -1458,91 +1461,121 @@ private fun GroupInviteInlineCard(
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = cardBackground,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, cardBorder)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(34.dp)
|
||||
.clip(CircleShape)
|
||||
.background(accentColor.copy(alpha = if (isOutgoing) 0.25f else 0.15f)),
|
||||
contentAlignment = Alignment.Center
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Link,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
color = titleColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = subtitleColor,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Surface(
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.clip(RoundedCornerShape(8.dp)).clickable(
|
||||
enabled = actionEnabled,
|
||||
onClick = ::handleAction
|
||||
),
|
||||
color =
|
||||
if (isOutgoing) {
|
||||
Color.White.copy(alpha = 0.2f)
|
||||
} else {
|
||||
accentColor.copy(alpha = 0.14f)
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
Modifier.size(34.dp)
|
||||
.clip(CircleShape)
|
||||
.background(accentColor.copy(alpha = if (isOutgoing) 0.25f else 0.15f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
Icon(
|
||||
imageVector = Icons.Default.Link,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
color = titleColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = subtitleColor,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier.clip(RoundedCornerShape(8.dp)).clickable(
|
||||
enabled = actionEnabled,
|
||||
onClick = ::handleAction
|
||||
),
|
||||
color =
|
||||
if (isOutgoing) {
|
||||
Color.White.copy(alpha = 0.2f)
|
||||
} else {
|
||||
accentColor.copy(alpha = 0.14f)
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
if (actionLoading || statusLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(12.dp),
|
||||
strokeWidth = 1.8.dp,
|
||||
color = accentColor
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = actionIcon,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(12.dp)
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (actionLoading || statusLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(12.dp),
|
||||
strokeWidth = 1.8.dp,
|
||||
color = accentColor
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = actionIcon,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(12.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = actionLabel,
|
||||
color = accentColor,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = actionLabel,
|
||||
color = accentColor,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = timeFormat.format(timestamp),
|
||||
color = timeColor,
|
||||
fontSize = 11.sp,
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
||||
)
|
||||
if (isOutgoing) {
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
AnimatedMessageStatus(
|
||||
status = messageStatus,
|
||||
timeColor = statusColor,
|
||||
isDarkTheme = isDarkTheme,
|
||||
isOutgoing = true,
|
||||
timestamp = timestamp.time,
|
||||
onRetry = onRetry,
|
||||
onDelete = onDelete
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user