fix: Большое количество изменений
This commit is contained in:
@@ -45,6 +45,10 @@ object CryptoManager {
|
||||
// ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной
|
||||
// расшифровке
|
||||
private const val DECRYPTION_CACHE_SIZE = 2000
|
||||
// Не кэшируем большие payload (вложения), чтобы избежать OOM на конкатенации cache key
|
||||
// и хранения гигантских plaintext в памяти.
|
||||
private const val MAX_CACHEABLE_ENCRYPTED_CHARS = 64 * 1024
|
||||
private const val MAX_CACHEABLE_DECRYPTED_CHARS = 64 * 1024
|
||||
private val decryptionCache = ConcurrentHashMap<String, String>(DECRYPTION_CACHE_SIZE, 0.75f, 4)
|
||||
|
||||
init {
|
||||
@@ -298,17 +302,21 @@ object CryptoManager {
|
||||
* 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений
|
||||
*/
|
||||
fun decryptWithPassword(encryptedData: String, password: String): String? {
|
||||
val useCache = encryptedData.length <= MAX_CACHEABLE_ENCRYPTED_CHARS
|
||||
val cacheKey = if (useCache) "$password:$encryptedData" else null
|
||||
|
||||
// 🚀 ОПТИМИЗАЦИЯ: Lock-free проверка кэша (ConcurrentHashMap)
|
||||
val cacheKey = "$password:$encryptedData"
|
||||
decryptionCache[cacheKey]?.let {
|
||||
return it
|
||||
if (cacheKey != null) {
|
||||
decryptionCache[cacheKey]?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
val result = decryptWithPasswordInternal(encryptedData, password)
|
||||
|
||||
// 🚀 Сохраняем в кэш (lock-free)
|
||||
if (result != null) {
|
||||
if (cacheKey != null && result != null && result.length <= MAX_CACHEABLE_DECRYPTED_CHARS) {
|
||||
// Ограничиваем размер кэша
|
||||
if (decryptionCache.size >= DECRYPTION_CACHE_SIZE) {
|
||||
// Удаляем ~10% самых старых записей
|
||||
|
||||
@@ -856,7 +856,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
isOutgoing = fm.isOutgoing,
|
||||
publicKey = fm.senderPublicKey,
|
||||
senderName = fm.senderName,
|
||||
attachments = fm.attachments
|
||||
attachments = fm.attachments,
|
||||
chachaKeyPlainHex = fm.chachaKeyPlain
|
||||
)
|
||||
}
|
||||
_isForwardMode.value = true
|
||||
@@ -2160,7 +2161,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
forwardedList.add(ReplyData(
|
||||
messageId = fwdMessageId,
|
||||
senderName = senderDisplayName,
|
||||
text = fwdText,
|
||||
text = resolveReplyPreviewText(fwdText, fwdAttachments),
|
||||
isFromMe = fwdIsFromMe,
|
||||
isForwarded = true,
|
||||
forwardedFromName = senderDisplayName,
|
||||
@@ -2346,7 +2347,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
ReplyData(
|
||||
messageId = realMessageId,
|
||||
senderName = resolvedSenderName,
|
||||
text = replyText,
|
||||
text = resolveReplyPreviewText(replyText, originalAttachments),
|
||||
isFromMe = isReplyFromMe,
|
||||
isForwarded = isForwarded,
|
||||
forwardedFromName = forwardFromDisplay,
|
||||
@@ -2501,6 +2502,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveReplyPreviewText(
|
||||
text: String,
|
||||
attachments: List<MessageAttachment>
|
||||
): String {
|
||||
if (text.isNotBlank()) return text
|
||||
return when {
|
||||
attachments.any { it.type == AttachmentType.VOICE } -> "Voice Message"
|
||||
attachments.any { it.type == AttachmentType.VIDEO_CIRCLE } -> "Video Message"
|
||||
else -> text
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 Установить сообщения для Reply (как в React Native) Сохраняем publicKey отправителя для
|
||||
* правильного отображения цитаты
|
||||
@@ -2515,16 +2528,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
msg.senderPublicKey.trim().ifEmpty {
|
||||
if (msg.isOutgoing) sender else opponent
|
||||
}
|
||||
val resolvedAttachments =
|
||||
msg.attachments
|
||||
.filter { it.type != AttachmentType.MESSAGES }
|
||||
ReplyMessage(
|
||||
messageId = msg.id,
|
||||
text = msg.text,
|
||||
text = resolveReplyPreviewText(msg.text, resolvedAttachments),
|
||||
timestamp = msg.timestamp.time,
|
||||
isOutgoing = msg.isOutgoing,
|
||||
publicKey = resolvedPublicKey,
|
||||
senderName = msg.senderName,
|
||||
attachments =
|
||||
msg.attachments
|
||||
.filter { it.type != AttachmentType.MESSAGES },
|
||||
attachments = resolvedAttachments,
|
||||
chachaKeyPlainHex = msg.chachaKeyPlainHex
|
||||
)
|
||||
}
|
||||
@@ -2542,16 +2556,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
msg.senderPublicKey.trim().ifEmpty {
|
||||
if (msg.isOutgoing) sender else opponent
|
||||
}
|
||||
val resolvedAttachments =
|
||||
msg.attachments
|
||||
.filter { it.type != AttachmentType.MESSAGES }
|
||||
ReplyMessage(
|
||||
messageId = msg.id,
|
||||
text = msg.text,
|
||||
text = resolveReplyPreviewText(msg.text, resolvedAttachments),
|
||||
timestamp = msg.timestamp.time,
|
||||
isOutgoing = msg.isOutgoing,
|
||||
publicKey = resolvedPublicKey,
|
||||
senderName = msg.senderName,
|
||||
attachments =
|
||||
msg.attachments
|
||||
.filter { it.type != AttachmentType.MESSAGES }
|
||||
attachments = resolvedAttachments,
|
||||
chachaKeyPlainHex = msg.chachaKeyPlainHex
|
||||
)
|
||||
}
|
||||
_isForwardMode.value = true
|
||||
@@ -2942,7 +2958,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
ReplyData(
|
||||
messageId = firstReply.messageId,
|
||||
senderName = firstReplySenderName,
|
||||
text = firstReply.text,
|
||||
text = resolveReplyPreviewText(firstReply.text, replyAttachments),
|
||||
isFromMe = firstReply.isOutgoing,
|
||||
isForwarded = isForward,
|
||||
forwardedFromName =
|
||||
@@ -2972,7 +2988,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
ReplyData(
|
||||
messageId = msg.messageId,
|
||||
senderName = senderDisplayName,
|
||||
text = msg.text,
|
||||
text = resolveReplyPreviewText(msg.text, resolvedAttachments),
|
||||
isFromMe = msg.isOutgoing,
|
||||
isForwarded = true,
|
||||
forwardedFromName = senderDisplayName,
|
||||
@@ -3143,6 +3159,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
if (isForwardToSend) {
|
||||
put("forwarded", true)
|
||||
put("senderName", msg.senderName)
|
||||
if (msg.chachaKeyPlainHex.isNotEmpty()) {
|
||||
put("chacha_key_plain", msg.chachaKeyPlainHex)
|
||||
}
|
||||
}
|
||||
}
|
||||
replyJsonArray.put(replyJson)
|
||||
|
||||
@@ -74,6 +74,7 @@ import com.rosetta.messenger.ui.chats.calls.CallsHistoryScreen
|
||||
import com.rosetta.messenger.ui.chats.calls.CallTopBanner
|
||||
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
|
||||
import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner
|
||||
import com.rosetta.messenger.ui.chats.components.VoicePlaybackCoordinator
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||
@@ -222,6 +223,18 @@ private fun isTypingForDialog(dialogKey: String, typingDialogs: Set<String>): Bo
|
||||
return typingDialogs.any { it.equals(dialogKey.trim(), ignoreCase = true) }
|
||||
}
|
||||
|
||||
private fun isVoicePlayingForDialog(dialogKey: String, playingDialogKey: String?): Boolean {
|
||||
val active = playingDialogKey?.trim().orEmpty()
|
||||
if (active.isEmpty()) return false
|
||||
if (isGroupDialogKey(dialogKey) || isGroupDialogKey(active)) {
|
||||
return normalizeGroupDialogKey(dialogKey).equals(
|
||||
normalizeGroupDialogKey(active),
|
||||
ignoreCase = true
|
||||
)
|
||||
}
|
||||
return dialogKey.trim().equals(active, ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun shortPublicKey(value: String): String {
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.length <= 12) return trimmed
|
||||
@@ -467,6 +480,12 @@ fun ChatsListScreen(
|
||||
// <20>🔥 Пользователи, которые сейчас печатают
|
||||
val typingUsers by ProtocolManager.typingUsers.collectAsState()
|
||||
val typingUsersByDialogSnapshot by ProtocolManager.typingUsersByDialogSnapshot.collectAsState()
|
||||
val playingVoiceAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
|
||||
val playingVoiceDialogKey by VoicePlaybackCoordinator.playingDialogKey.collectAsState()
|
||||
val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState()
|
||||
val voicePlaybackSpeed by VoicePlaybackCoordinator.playbackSpeed.collectAsState()
|
||||
val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState()
|
||||
val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.collectAsState()
|
||||
|
||||
// Load dialogs when account is available
|
||||
LaunchedEffect(accountPublicKey, accountPrivateKey) {
|
||||
@@ -2130,6 +2149,50 @@ fun ChatsListScreen(
|
||||
callUiState.phase != CallPhase.INCOMING
|
||||
}
|
||||
val callBannerHeight = 40.dp
|
||||
val showVoiceMiniPlayer =
|
||||
remember(
|
||||
showRequestsScreen,
|
||||
showDownloadsScreen,
|
||||
showCallsScreen,
|
||||
playingVoiceAttachmentId
|
||||
) {
|
||||
!showRequestsScreen &&
|
||||
!showDownloadsScreen &&
|
||||
!showCallsScreen &&
|
||||
!playingVoiceAttachmentId.isNullOrBlank()
|
||||
}
|
||||
val voiceBannerHeight = 36.dp
|
||||
val stickyTopInset =
|
||||
remember(
|
||||
showStickyCallBanner,
|
||||
showVoiceMiniPlayer
|
||||
) {
|
||||
var topInset = 0.dp
|
||||
if (showStickyCallBanner) {
|
||||
topInset += callBannerHeight
|
||||
}
|
||||
if (showVoiceMiniPlayer) {
|
||||
topInset += voiceBannerHeight
|
||||
}
|
||||
topInset
|
||||
}
|
||||
val voiceMiniPlayerTitle =
|
||||
remember(
|
||||
playingVoiceSenderLabel,
|
||||
playingVoiceTimeLabel
|
||||
) {
|
||||
val sender =
|
||||
playingVoiceSenderLabel
|
||||
.trim()
|
||||
.ifBlank {
|
||||
"Voice"
|
||||
}
|
||||
val time =
|
||||
playingVoiceTimeLabel
|
||||
.trim()
|
||||
if (time.isBlank()) sender
|
||||
else "$sender at $time"
|
||||
}
|
||||
// 🔥 Берем dialogs из chatsState для
|
||||
// консистентности
|
||||
// 📌 Порядок по времени готовится в ViewModel.
|
||||
@@ -2332,9 +2395,7 @@ fun ChatsListScreen(
|
||||
Modifier.fillMaxSize()
|
||||
.padding(
|
||||
top =
|
||||
if (showStickyCallBanner)
|
||||
callBannerHeight
|
||||
else 0.dp
|
||||
stickyTopInset
|
||||
)
|
||||
.then(
|
||||
if (requestsCount > 0) Modifier.nestedScroll(requestsNestedScroll)
|
||||
@@ -2572,6 +2633,18 @@ fun ChatsListScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
val isVoicePlaybackActive by
|
||||
remember(
|
||||
dialog.opponentKey,
|
||||
playingVoiceDialogKey
|
||||
) {
|
||||
derivedStateOf {
|
||||
isVoicePlayingForDialog(
|
||||
dialog.opponentKey,
|
||||
playingVoiceDialogKey
|
||||
)
|
||||
}
|
||||
}
|
||||
val isSelectedDialog =
|
||||
selectedChatKeys
|
||||
.contains(
|
||||
@@ -2613,6 +2686,8 @@ fun ChatsListScreen(
|
||||
typingDisplayName,
|
||||
typingSenderPublicKey =
|
||||
typingSenderPublicKey,
|
||||
isVoicePlaybackActive =
|
||||
isVoicePlaybackActive,
|
||||
isBlocked =
|
||||
isBlocked,
|
||||
isSavedMessages =
|
||||
@@ -2746,14 +2821,41 @@ fun ChatsListScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showStickyCallBanner) {
|
||||
CallTopBanner(
|
||||
state = callUiState,
|
||||
isSticky = true,
|
||||
isDarkTheme = isDarkTheme,
|
||||
avatarRepository = avatarRepository,
|
||||
onOpenCall = onOpenCallOverlay
|
||||
)
|
||||
if (showStickyCallBanner || showVoiceMiniPlayer) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.align(
|
||||
Alignment.TopCenter
|
||||
)
|
||||
) {
|
||||
if (showStickyCallBanner) {
|
||||
CallTopBanner(
|
||||
state = callUiState,
|
||||
isSticky = true,
|
||||
isDarkTheme = isDarkTheme,
|
||||
avatarRepository = avatarRepository,
|
||||
onOpenCall = onOpenCallOverlay
|
||||
)
|
||||
}
|
||||
if (showVoiceMiniPlayer) {
|
||||
VoiceTopMiniPlayer(
|
||||
title = voiceMiniPlayerTitle,
|
||||
isDarkTheme = isDarkTheme,
|
||||
isPlaying = isVoicePlaybackRunning,
|
||||
speed = voicePlaybackSpeed,
|
||||
onTogglePlay = {
|
||||
VoicePlaybackCoordinator.toggleCurrentPlayback()
|
||||
},
|
||||
onCycleSpeed = {
|
||||
VoicePlaybackCoordinator.cycleSpeed()
|
||||
},
|
||||
onClose = {
|
||||
VoicePlaybackCoordinator.stop()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3722,6 +3824,7 @@ fun SwipeableDialogItem(
|
||||
isTyping: Boolean = false,
|
||||
typingDisplayName: String = "",
|
||||
typingSenderPublicKey: String = "",
|
||||
isVoicePlaybackActive: Boolean = false,
|
||||
isBlocked: Boolean = false,
|
||||
isGroupChat: Boolean = false,
|
||||
isSavedMessages: Boolean = false,
|
||||
@@ -4125,6 +4228,7 @@ fun SwipeableDialogItem(
|
||||
isTyping = isTyping,
|
||||
typingDisplayName = typingDisplayName,
|
||||
typingSenderPublicKey = typingSenderPublicKey,
|
||||
isVoicePlaybackActive = isVoicePlaybackActive,
|
||||
isPinned = isPinned,
|
||||
isBlocked = isBlocked,
|
||||
isMuted = isMuted,
|
||||
@@ -4144,6 +4248,7 @@ fun DialogItemContent(
|
||||
isTyping: Boolean = false,
|
||||
typingDisplayName: String = "",
|
||||
typingSenderPublicKey: String = "",
|
||||
isVoicePlaybackActive: Boolean = false,
|
||||
isPinned: Boolean = false,
|
||||
isBlocked: Boolean = false,
|
||||
isMuted: Boolean = false,
|
||||
@@ -4278,12 +4383,12 @@ fun DialogItemContent(
|
||||
// Name and last message
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 22.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier.weight(1f).heightIn(min = 22.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AppleEmojiText(
|
||||
@@ -4293,7 +4398,8 @@ fun DialogItemContent(
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
overflow = android.text.TextUtils.TruncateAt.END,
|
||||
enableLinks = false
|
||||
enableLinks = false,
|
||||
minHeightMultiplier = 1f
|
||||
)
|
||||
if (isGroupDialog) {
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
@@ -4301,7 +4407,7 @@ fun DialogItemContent(
|
||||
imageVector = TablerIcons.Users,
|
||||
contentDescription = null,
|
||||
tint = secondaryTextColor.copy(alpha = 0.9f),
|
||||
modifier = Modifier.size(15.dp)
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
val isOfficialByKey = MessageRepository.isSystemAccount(dialog.opponentKey)
|
||||
@@ -4310,7 +4416,7 @@ fun DialogItemContent(
|
||||
VerifiedBadge(
|
||||
verified = if (dialog.verified > 0) dialog.verified else 1,
|
||||
size = 16,
|
||||
modifier = Modifier.offset(y = (-2).dp),
|
||||
modifier = Modifier,
|
||||
isDarkTheme = isDarkTheme,
|
||||
badgeTint = PrimaryBlue
|
||||
)
|
||||
@@ -4337,6 +4443,7 @@ fun DialogItemContent(
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.heightIn(min = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
@@ -4467,7 +4574,7 @@ fun DialogItemContent(
|
||||
0.6f
|
||||
),
|
||||
modifier =
|
||||
Modifier.size(14.dp)
|
||||
Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(
|
||||
modifier =
|
||||
@@ -4487,9 +4594,11 @@ fun DialogItemContent(
|
||||
Text(
|
||||
text = formattedTime,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 13.sp,
|
||||
color =
|
||||
if (visibleUnreadCount > 0) PrimaryBlue
|
||||
else secondaryTextColor
|
||||
else secondaryTextColor,
|
||||
modifier = Modifier.align(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4506,18 +4615,35 @@ fun DialogItemContent(
|
||||
modifier = Modifier.weight(1f).heightIn(min = 20.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
val subtitleMode =
|
||||
remember(
|
||||
isVoicePlaybackActive,
|
||||
isTyping,
|
||||
dialog.draftText
|
||||
) {
|
||||
when {
|
||||
isVoicePlaybackActive -> "voice"
|
||||
isTyping -> "typing"
|
||||
!dialog.draftText.isNullOrEmpty() -> "draft"
|
||||
else -> "message"
|
||||
}
|
||||
}
|
||||
Crossfade(
|
||||
targetState = isTyping,
|
||||
targetState = subtitleMode,
|
||||
animationSpec = tween(150),
|
||||
label = "chatSubtitle"
|
||||
) { showTyping ->
|
||||
if (showTyping) {
|
||||
) { mode ->
|
||||
if (mode == "voice") {
|
||||
VoicePlaybackIndicatorSmall(
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
} else if (mode == "typing") {
|
||||
TypingIndicatorSmall(
|
||||
isDarkTheme = isDarkTheme,
|
||||
typingDisplayName = typingDisplayName,
|
||||
typingSenderPublicKey = typingSenderPublicKey
|
||||
)
|
||||
} else if (!dialog.draftText.isNullOrEmpty()) {
|
||||
} else if (mode == "draft") {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Draft: ",
|
||||
@@ -4527,7 +4653,7 @@ fun DialogItemContent(
|
||||
maxLines = 1
|
||||
)
|
||||
AppleEmojiText(
|
||||
text = dialog.draftText,
|
||||
text = dialog.draftText.orEmpty(),
|
||||
modifier = Modifier.weight(1f),
|
||||
fontSize = 14.sp,
|
||||
color = secondaryTextColor,
|
||||
@@ -4868,6 +4994,161 @@ fun TypingIndicatorSmall(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoicePlaybackIndicatorSmall(
|
||||
isDarkTheme: Boolean
|
||||
) {
|
||||
val accentColor = if (isDarkTheme) PrimaryBlue else Color(0xFF2481CC)
|
||||
val transition = rememberInfiniteTransition(label = "voicePlaybackIndicator")
|
||||
val levels = List(3) { index ->
|
||||
transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = keyframes {
|
||||
durationMillis = 900
|
||||
0f at 0
|
||||
1f at 280
|
||||
0.2f at 580
|
||||
0f at 900
|
||||
},
|
||||
repeatMode = RepeatMode.Restart,
|
||||
initialStartOffset = StartOffset(index * 130)
|
||||
),
|
||||
label = "voiceBar$index"
|
||||
).value
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.heightIn(min = 18.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Canvas(modifier = Modifier.size(width = 14.dp, height = 12.dp)) {
|
||||
val barWidth = 2.dp.toPx()
|
||||
val gap = 2.dp.toPx()
|
||||
val baseY = size.height
|
||||
repeat(3) { index ->
|
||||
val x = index * (barWidth + gap)
|
||||
val progress = levels[index].coerceIn(0f, 1f)
|
||||
val minH = 3.dp.toPx()
|
||||
val maxH = 10.dp.toPx()
|
||||
val height = minH + (maxH - minH) * progress
|
||||
drawRoundRect(
|
||||
color = accentColor.copy(alpha = 0.6f + progress * 0.4f),
|
||||
topLeft = Offset(x, baseY - height),
|
||||
size = androidx.compose.ui.geometry.Size(barWidth, height),
|
||||
cornerRadius =
|
||||
androidx.compose.ui.geometry.CornerRadius(
|
||||
x = barWidth,
|
||||
y = barWidth
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
AppleEmojiText(
|
||||
text = "Listening",
|
||||
fontSize = 14.sp,
|
||||
color = accentColor,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = android.text.TextUtils.TruncateAt.END,
|
||||
enableLinks = false,
|
||||
minHeightMultiplier = 1f
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatVoiceSpeedLabel(speed: Float): String {
|
||||
val normalized = (speed * 10f).roundToInt() / 10f
|
||||
return if (kotlin.math.abs(normalized - normalized.toInt().toFloat()) < 0.01f) {
|
||||
"${normalized.toInt()}x"
|
||||
} else {
|
||||
"${normalized}x"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceTopMiniPlayer(
|
||||
title: String,
|
||||
isDarkTheme: Boolean,
|
||||
isPlaying: Boolean,
|
||||
speed: Float,
|
||||
onTogglePlay: () -> Unit,
|
||||
onCycleSpeed: () -> Unit,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
val containerColor = if (isDarkTheme) Color(0xFF203446) else Color(0xFFEAF4FF)
|
||||
val accentColor = if (isDarkTheme) Color(0xFF58AAFF) else Color(0xFF2481CC)
|
||||
val textColor = if (isDarkTheme) Color(0xFFF3F8FF) else Color(0xFF183047)
|
||||
val secondaryColor = if (isDarkTheme) Color(0xFF9EB6CC) else Color(0xFF4F6F8A)
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.height(36.dp)
|
||||
.background(containerColor)
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onTogglePlay,
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (isPlaying) TablerIcons.PlayerPause
|
||||
else TablerIcons.PlayerPlay,
|
||||
contentDescription = if (isPlaying) "Pause voice" else "Play voice",
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
|
||||
AppleEmojiText(
|
||||
text = title,
|
||||
fontSize = 14.sp,
|
||||
color = textColor,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = android.text.TextUtils.TruncateAt.END,
|
||||
modifier = Modifier.weight(1f),
|
||||
enableLinks = false,
|
||||
minHeightMultiplier = 1f
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.clip(RoundedCornerShape(8.dp))
|
||||
.border(1.dp, accentColor.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
|
||||
.clickable { onCycleSpeed() }
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = formatVoiceSpeedLabel(speed),
|
||||
color = accentColor,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
|
||||
IconButton(
|
||||
onClick = onClose,
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.X,
|
||||
contentDescription = "Close voice",
|
||||
tint = secondaryColor,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwipeBackContainer(
|
||||
onBack: () -> Unit,
|
||||
@@ -5467,7 +5748,7 @@ fun DrawerMenuItemEnhanced(
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = textColor,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
@@ -5527,7 +5808,7 @@ fun DrawerMenuItemEnhanced(
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = textColor,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
@@ -5561,7 +5842,7 @@ fun DrawerMenuItemEnhanced(
|
||||
fun DrawerDivider(isDarkTheme: Boolean) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Divider(
|
||||
modifier = Modifier.padding(horizontal = 20.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFC8C8CC),
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
|
||||
@@ -573,23 +573,23 @@ private fun ChatsTabContent(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Recent header (always show with Clear All) ───
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 14.dp, bottom = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"Recent",
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = PrimaryBlue
|
||||
)
|
||||
if (recentUsers.isNotEmpty()) {
|
||||
// ─── Recent header (only when there are recents) ───
|
||||
if (recentUsers.isNotEmpty()) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 14.dp, bottom = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"Recent",
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = PrimaryBlue
|
||||
)
|
||||
Text(
|
||||
"Clear All",
|
||||
fontSize = 13.sp,
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.graphics.Matrix
|
||||
import android.media.AudioAttributes
|
||||
import android.media.MediaPlayer
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.util.Base64
|
||||
import android.util.LruCache
|
||||
@@ -91,6 +92,8 @@ import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import kotlin.math.min
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.core.content.FileProvider
|
||||
@@ -153,25 +156,48 @@ private fun decodeVoicePayload(data: String): ByteArray? {
|
||||
return decodeHexPayload(data) ?: decodeBase64Payload(data)
|
||||
}
|
||||
|
||||
private object VoicePlaybackCoordinator {
|
||||
object VoicePlaybackCoordinator {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private val speedSteps = listOf(1f, 1.5f, 2f)
|
||||
private var player: MediaPlayer? = null
|
||||
private var currentAttachmentId: String? = null
|
||||
private var progressJob: Job? = null
|
||||
private val _playingAttachmentId = MutableStateFlow<String?>(null)
|
||||
val playingAttachmentId: StateFlow<String?> = _playingAttachmentId.asStateFlow()
|
||||
private val _playingDialogKey = MutableStateFlow<String?>(null)
|
||||
val playingDialogKey: StateFlow<String?> = _playingDialogKey.asStateFlow()
|
||||
private val _positionMs = MutableStateFlow(0)
|
||||
val positionMs: StateFlow<Int> = _positionMs.asStateFlow()
|
||||
private val _durationMs = MutableStateFlow(0)
|
||||
val durationMs: StateFlow<Int> = _durationMs.asStateFlow()
|
||||
private val _isPlaying = MutableStateFlow(false)
|
||||
val isPlaying: StateFlow<Boolean> = _isPlaying.asStateFlow()
|
||||
private val _playbackSpeed = MutableStateFlow(1f)
|
||||
val playbackSpeed: StateFlow<Float> = _playbackSpeed.asStateFlow()
|
||||
private val _playingSenderLabel = MutableStateFlow("")
|
||||
val playingSenderLabel: StateFlow<String> = _playingSenderLabel.asStateFlow()
|
||||
private val _playingTimeLabel = MutableStateFlow("")
|
||||
val playingTimeLabel: StateFlow<String> = _playingTimeLabel.asStateFlow()
|
||||
|
||||
fun toggle(attachmentId: String, sourceFile: File, onError: (String) -> Unit = {}) {
|
||||
fun toggle(
|
||||
attachmentId: String,
|
||||
sourceFile: File,
|
||||
dialogKey: String = "",
|
||||
senderLabel: String = "",
|
||||
playedAtLabel: String = "",
|
||||
onError: (String) -> Unit = {}
|
||||
) {
|
||||
if (!sourceFile.exists()) {
|
||||
onError("Voice file is missing")
|
||||
return
|
||||
}
|
||||
if (currentAttachmentId == attachmentId && player?.isPlaying == true) {
|
||||
stop()
|
||||
|
||||
if (currentAttachmentId == attachmentId && player != null) {
|
||||
if (_isPlaying.value) {
|
||||
pause()
|
||||
} else {
|
||||
resume(onError = onError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -187,22 +213,18 @@ private object VoicePlaybackCoordinator {
|
||||
mediaPlayer.setDataSource(sourceFile.absolutePath)
|
||||
mediaPlayer.setOnCompletionListener { stop() }
|
||||
mediaPlayer.prepare()
|
||||
applyPlaybackSpeed(mediaPlayer)
|
||||
mediaPlayer.start()
|
||||
player = mediaPlayer
|
||||
currentAttachmentId = attachmentId
|
||||
_playingAttachmentId.value = attachmentId
|
||||
_playingDialogKey.value = dialogKey.trim().ifBlank { null }
|
||||
_playingSenderLabel.value = senderLabel.trim()
|
||||
_playingTimeLabel.value = playedAtLabel.trim()
|
||||
_durationMs.value = mediaPlayer.duration.coerceAtLeast(0)
|
||||
_positionMs.value = mediaPlayer.currentPosition.coerceAtLeast(0)
|
||||
progressJob?.cancel()
|
||||
progressJob =
|
||||
scope.launch {
|
||||
while (isActive && currentAttachmentId == attachmentId) {
|
||||
val active = player
|
||||
if (active == null || !active.isPlaying) break
|
||||
_positionMs.value = active.currentPosition.coerceAtLeast(0)
|
||||
delay(120)
|
||||
}
|
||||
}
|
||||
_isPlaying.value = true
|
||||
startProgressUpdates(attachmentId)
|
||||
} catch (e: Exception) {
|
||||
runCatching { mediaPlayer.release() }
|
||||
stop()
|
||||
@@ -210,6 +232,80 @@ private object VoicePlaybackCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleCurrentPlayback(onError: (String) -> Unit = {}) {
|
||||
if (player == null || currentAttachmentId.isNullOrBlank()) return
|
||||
if (_isPlaying.value) {
|
||||
pause()
|
||||
} else {
|
||||
resume(onError = onError)
|
||||
}
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
val active = player ?: return
|
||||
runCatching {
|
||||
if (active.isPlaying) active.pause()
|
||||
}
|
||||
_isPlaying.value = false
|
||||
progressJob?.cancel()
|
||||
progressJob = null
|
||||
_positionMs.value = active.currentPosition.coerceAtLeast(0)
|
||||
}
|
||||
|
||||
fun resume(onError: (String) -> Unit = {}) {
|
||||
val active = player ?: return
|
||||
val attachmentId = currentAttachmentId
|
||||
if (attachmentId.isNullOrBlank()) return
|
||||
try {
|
||||
applyPlaybackSpeed(active)
|
||||
active.start()
|
||||
_durationMs.value = active.duration.coerceAtLeast(0)
|
||||
_positionMs.value = active.currentPosition.coerceAtLeast(0)
|
||||
_isPlaying.value = true
|
||||
startProgressUpdates(attachmentId)
|
||||
} catch (e: Exception) {
|
||||
stop()
|
||||
onError(e.message ?: "Playback failed")
|
||||
}
|
||||
}
|
||||
|
||||
fun cycleSpeed() {
|
||||
val current = _playbackSpeed.value
|
||||
val currentIndex = speedSteps.indexOfFirst { kotlin.math.abs(it - current) < 0.01f }
|
||||
val next = if (currentIndex < 0) speedSteps.first() else speedSteps[(currentIndex + 1) % speedSteps.size]
|
||||
setPlaybackSpeed(next)
|
||||
}
|
||||
|
||||
private fun setPlaybackSpeed(speed: Float) {
|
||||
val normalized =
|
||||
speedSteps.minByOrNull { kotlin.math.abs(it - speed) } ?: speedSteps.first()
|
||||
_playbackSpeed.value = normalized
|
||||
player?.let { applyPlaybackSpeed(it) }
|
||||
}
|
||||
|
||||
private fun applyPlaybackSpeed(mediaPlayer: MediaPlayer) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
|
||||
runCatching {
|
||||
val current = mediaPlayer.playbackParams
|
||||
mediaPlayer.playbackParams = current.setSpeed(_playbackSpeed.value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startProgressUpdates(attachmentId: String) {
|
||||
progressJob?.cancel()
|
||||
progressJob =
|
||||
scope.launch {
|
||||
while (isActive && currentAttachmentId == attachmentId) {
|
||||
val active = player ?: break
|
||||
_positionMs.value = active.currentPosition.coerceAtLeast(0)
|
||||
_durationMs.value = active.duration.coerceAtLeast(0)
|
||||
if (!active.isPlaying) break
|
||||
delay(120)
|
||||
}
|
||||
_isPlaying.value = player?.isPlaying == true && currentAttachmentId == attachmentId
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
val active = player
|
||||
player = null
|
||||
@@ -217,8 +313,12 @@ private object VoicePlaybackCoordinator {
|
||||
progressJob?.cancel()
|
||||
progressJob = null
|
||||
_playingAttachmentId.value = null
|
||||
_playingDialogKey.value = null
|
||||
_playingSenderLabel.value = ""
|
||||
_playingTimeLabel.value = ""
|
||||
_positionMs.value = 0
|
||||
_durationMs.value = 0
|
||||
_isPlaying.value = false
|
||||
if (active != null) {
|
||||
runCatching {
|
||||
if (active.isPlaying) active.stop()
|
||||
@@ -593,6 +693,7 @@ fun MessageAttachments(
|
||||
isOutgoing: Boolean,
|
||||
isDarkTheme: Boolean,
|
||||
senderPublicKey: String,
|
||||
senderDisplayName: String = "",
|
||||
dialogPublicKey: String = "",
|
||||
isGroupChat: Boolean = false,
|
||||
timestamp: java.util.Date,
|
||||
@@ -683,6 +784,8 @@ fun MessageAttachments(
|
||||
chachaKeyPlainHex = chachaKeyPlainHex,
|
||||
privateKey = privateKey,
|
||||
senderPublicKey = senderPublicKey,
|
||||
senderDisplayName = senderDisplayName,
|
||||
dialogPublicKey = dialogPublicKey,
|
||||
isOutgoing = isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = timestamp,
|
||||
@@ -2036,6 +2139,8 @@ private fun VoiceAttachment(
|
||||
chachaKeyPlainHex: String,
|
||||
privateKey: String,
|
||||
senderPublicKey: String,
|
||||
senderDisplayName: String,
|
||||
dialogPublicKey: String,
|
||||
isOutgoing: Boolean,
|
||||
isDarkTheme: Boolean,
|
||||
timestamp: java.util.Date,
|
||||
@@ -2043,10 +2148,12 @@ private fun VoiceAttachment(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val playingAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
|
||||
val activeAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
|
||||
val playbackPositionMs by VoicePlaybackCoordinator.positionMs.collectAsState()
|
||||
val playbackDurationMs by VoicePlaybackCoordinator.durationMs.collectAsState()
|
||||
val isPlaying = playingAttachmentId == attachment.id
|
||||
val playbackIsPlaying by VoicePlaybackCoordinator.isPlaying.collectAsState()
|
||||
val isActiveTrack = activeAttachmentId == attachment.id
|
||||
val isPlaying = isActiveTrack && playbackIsPlaying
|
||||
|
||||
val (previewDurationSecRaw, previewWavesRaw) =
|
||||
remember(attachment.preview) { parseVoicePreview(attachment.preview) }
|
||||
@@ -2078,21 +2185,37 @@ private fun VoiceAttachment(
|
||||
val effectiveDurationSec =
|
||||
remember(isPlaying, playbackDurationMs, previewDurationSec) {
|
||||
val fromPlayer = (playbackDurationMs / 1000).coerceAtLeast(0)
|
||||
if (isPlaying && fromPlayer > 0) fromPlayer else previewDurationSec
|
||||
if (isActiveTrack && fromPlayer > 0) fromPlayer else previewDurationSec
|
||||
}
|
||||
val progress =
|
||||
if (isPlaying && playbackDurationMs > 0) {
|
||||
if (isActiveTrack && playbackDurationMs > 0) {
|
||||
(playbackPositionMs.toFloat() / playbackDurationMs.toFloat()).coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
val timeText =
|
||||
if (isPlaying && playbackDurationMs > 0) {
|
||||
if (isActiveTrack && playbackDurationMs > 0) {
|
||||
val leftSec = ((playbackDurationMs - playbackPositionMs).coerceAtLeast(0) / 1000)
|
||||
"-${formatVoiceDuration(leftSec)}"
|
||||
formatVoiceDuration(leftSec)
|
||||
} else {
|
||||
formatVoiceDuration(effectiveDurationSec)
|
||||
}
|
||||
val playbackSenderLabel =
|
||||
remember(isOutgoing, senderDisplayName) {
|
||||
val senderName = senderDisplayName.trim()
|
||||
when {
|
||||
isOutgoing -> "You"
|
||||
senderName.isNotBlank() -> senderName
|
||||
else -> "Voice"
|
||||
}
|
||||
}
|
||||
val playbackTimeLabel =
|
||||
remember(timestamp.time) {
|
||||
runCatching {
|
||||
SimpleDateFormat("h:mm a", Locale.getDefault()).format(timestamp)
|
||||
}
|
||||
.getOrDefault("")
|
||||
}
|
||||
|
||||
LaunchedEffect(payload, attachment.id) {
|
||||
if (payload.isBlank()) return@LaunchedEffect
|
||||
@@ -2112,14 +2235,6 @@ private fun VoiceAttachment(
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(attachment.id) {
|
||||
onDispose {
|
||||
if (playingAttachmentId == attachment.id) {
|
||||
VoicePlaybackCoordinator.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val triggerDownload: () -> Unit = download@{
|
||||
if (attachment.transportTag.isBlank()) {
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
@@ -2172,9 +2287,15 @@ private fun VoiceAttachment(
|
||||
if (file == null || !file.exists()) {
|
||||
if (payload.isNotBlank()) {
|
||||
val prepared = ensureVoiceAudioFile(context, attachment.id, payload)
|
||||
if (prepared != null) {
|
||||
audioFilePath = prepared.absolutePath
|
||||
VoicePlaybackCoordinator.toggle(attachment.id, prepared) { message ->
|
||||
if (prepared != null) {
|
||||
audioFilePath = prepared.absolutePath
|
||||
VoicePlaybackCoordinator.toggle(
|
||||
attachmentId = attachment.id,
|
||||
sourceFile = prepared,
|
||||
dialogKey = dialogPublicKey,
|
||||
senderLabel = playbackSenderLabel,
|
||||
playedAtLabel = playbackTimeLabel
|
||||
) { message ->
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
errorText = message
|
||||
}
|
||||
@@ -2186,7 +2307,13 @@ private fun VoiceAttachment(
|
||||
triggerDownload()
|
||||
}
|
||||
} else {
|
||||
VoicePlaybackCoordinator.toggle(attachment.id, file) { message ->
|
||||
VoicePlaybackCoordinator.toggle(
|
||||
attachmentId = attachment.id,
|
||||
sourceFile = file,
|
||||
dialogKey = dialogPublicKey,
|
||||
senderLabel = playbackSenderLabel,
|
||||
playedAtLabel = playbackTimeLabel
|
||||
) { message ->
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
errorText = message
|
||||
}
|
||||
|
||||
@@ -355,6 +355,16 @@ fun MessageBubble(
|
||||
onGroupInviteOpen: (SearchUser) -> Unit = {},
|
||||
contextMenuContent: @Composable () -> Unit = {}
|
||||
) {
|
||||
val isTextSelectionOnThisMessage =
|
||||
remember(
|
||||
textSelectionHelper?.isInSelectionMode,
|
||||
textSelectionHelper?.selectedMessageId,
|
||||
message.id
|
||||
) {
|
||||
textSelectionHelper?.isInSelectionMode == true &&
|
||||
textSelectionHelper.selectedMessageId == message.id
|
||||
}
|
||||
|
||||
// Swipe-to-reply state
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
var swipeOffset by remember { mutableStateOf(0f) }
|
||||
@@ -374,7 +384,7 @@ fun MessageBubble(
|
||||
// Selection animations
|
||||
val selectionAlpha by
|
||||
animateFloatAsState(
|
||||
targetValue = if (isSelected) 0.85f else 1f,
|
||||
targetValue = if (isSelected && !isTextSelectionOnThisMessage) 0.85f else 1f,
|
||||
animationSpec = tween(150),
|
||||
label = "selectionAlpha"
|
||||
)
|
||||
@@ -558,7 +568,8 @@ fun MessageBubble(
|
||||
val selectionBackgroundColor by
|
||||
animateColorAsState(
|
||||
targetValue =
|
||||
if (isSelected) PrimaryBlue.copy(alpha = 0.15f)
|
||||
if (isSelected && !isTextSelectionOnThisMessage)
|
||||
PrimaryBlue.copy(alpha = 0.15f)
|
||||
else Color.Transparent,
|
||||
animationSpec = tween(200),
|
||||
label = "selectionBg"
|
||||
@@ -1004,6 +1015,7 @@ fun MessageBubble(
|
||||
isOutgoing = message.isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
senderPublicKey = senderPublicKey,
|
||||
senderDisplayName = senderName,
|
||||
dialogPublicKey = dialogPublicKey,
|
||||
isGroupChat = isGroupChat,
|
||||
timestamp = message.timestamp,
|
||||
@@ -2381,6 +2393,8 @@ fun ReplyBubble(
|
||||
)
|
||||
} else if (!hasImage) {
|
||||
val displayText = when {
|
||||
replyData.attachments.any { it.type == AttachmentType.VOICE } -> "Voice Message"
|
||||
replyData.attachments.any { it.type == AttachmentType.VIDEO_CIRCLE } -> "Video Message"
|
||||
replyData.attachments.any { it.type == AttachmentType.FILE } -> "File"
|
||||
replyData.attachments.any { it.type == AttachmentType.CALL } -> "Call"
|
||||
else -> "..."
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,9 @@ import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -160,7 +163,7 @@ fun SwipeBackContainer(
|
||||
// Alpha animation for fade-in entry
|
||||
val alphaAnimatable = remember { Animatable(0f) }
|
||||
|
||||
// Drag state - direct update without animation
|
||||
// Drag state
|
||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -177,6 +180,7 @@ fun SwipeBackContainer(
|
||||
val context = LocalContext.current
|
||||
val view = LocalView.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val lifecycleOwner = view.findViewTreeLifecycleOwner()
|
||||
val dismissKeyboard: () -> Unit = {
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
@@ -187,21 +191,16 @@ fun SwipeBackContainer(
|
||||
focusManager.clearFocus(force = true)
|
||||
}
|
||||
|
||||
// Current offset: use drag offset during drag, animatable otherwise + optional enter slide
|
||||
val baseOffset = if (isDragging) dragOffset else offsetAnimatable.value
|
||||
val enterOffset =
|
||||
if (isAnimatingIn && enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
|
||||
enterOffsetAnimatable.value
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
val currentOffset = baseOffset + enterOffset
|
||||
fun computeCurrentOffset(): Float {
|
||||
val base = if (isDragging) dragOffset else offsetAnimatable.value
|
||||
val enter = if (isAnimatingIn && enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
|
||||
enterOffsetAnimatable.value
|
||||
} else 0f
|
||||
return base + enter
|
||||
}
|
||||
|
||||
// Current alpha: use animatable during fade animations, otherwise 1
|
||||
val currentAlpha = if (isAnimatingIn || isAnimatingOut) alphaAnimatable.value else 1f
|
||||
|
||||
// Scrim alpha based on swipe progress
|
||||
val scrimAlpha = (1f - (currentOffset / screenWidthPx).coerceIn(0f, 1f)) * 0.4f
|
||||
val sharedOwnerId = SwipeBackSharedProgress.ownerId
|
||||
val sharedOwnerLayer = SwipeBackSharedProgress.ownerLayer
|
||||
val sharedProgress = SwipeBackSharedProgress.progress
|
||||
@@ -239,6 +238,21 @@ fun SwipeBackContainer(
|
||||
}
|
||||
}
|
||||
|
||||
fun forceResetSwipeState() {
|
||||
isDragging = false
|
||||
dragOffset = 0f
|
||||
clearSharedSwipeProgressIfOwner()
|
||||
scope.launch {
|
||||
offsetAnimatable.snapTo(0f)
|
||||
if (enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
|
||||
enterOffsetAnimatable.snapTo(0f)
|
||||
}
|
||||
if (shouldShow && !isAnimatingOut) {
|
||||
alphaAnimatable.snapTo(1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle visibility changes
|
||||
// 🔥 FIX: try/finally ensures animation flags are ALWAYS reset even if
|
||||
// LaunchedEffect is cancelled by rapid isVisible changes (fast swipes).
|
||||
@@ -292,10 +306,34 @@ fun SwipeBackContainer(
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) { onDispose { clearSharedSwipeProgressIfOwner() } }
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
if (lifecycleOwner == null) {
|
||||
onDispose { }
|
||||
} else {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) {
|
||||
forceResetSwipeState()
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
forceResetSwipeState()
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldShow && !isAnimatingIn && !isAnimatingOut) return
|
||||
|
||||
val currentOffset = computeCurrentOffset()
|
||||
val swipeProgress = (currentOffset / screenWidthPx).coerceIn(0f, 1f)
|
||||
val scrimAlpha = if (isDragging || currentOffset > 0f) 0.14f * (1f - swipeProgress) else 0f
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxSize().graphicsLayer {
|
||||
@@ -346,13 +384,15 @@ fun SwipeBackContainer(
|
||||
var totalDragY = 0f
|
||||
var passedSlop = false
|
||||
var keyboardHiddenForGesture = false
|
||||
var resetOnFinally = true
|
||||
|
||||
// deferToChildren=true: pre-slop uses Main pass so children
|
||||
// (e.g. LazyRow) process first — if they consume, we back off.
|
||||
// deferToChildren=false (default): always use Initial pass
|
||||
// to intercept before children (original behavior).
|
||||
// Post-claim: always Initial to block children.
|
||||
while (true) {
|
||||
try {
|
||||
while (true) {
|
||||
val pass =
|
||||
if (startedSwipe || !deferToChildren)
|
||||
PointerEventPass.Initial
|
||||
@@ -365,6 +405,7 @@ fun SwipeBackContainer(
|
||||
?: break
|
||||
|
||||
if (change.changedToUpIgnoreConsumed()) {
|
||||
resetOnFinally = false
|
||||
break
|
||||
}
|
||||
|
||||
@@ -443,6 +484,13 @@ fun SwipeBackContainer(
|
||||
)
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Сбрасываем только при отмене/прерывании жеста.
|
||||
// При обычном UP сброс делаем позже, чтобы не было рывка.
|
||||
if (resetOnFinally && isDragging) {
|
||||
forceResetSwipeState()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle drag end
|
||||
|
||||
@@ -92,16 +92,8 @@ fun MyQrCodeScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var selectedThemeIndex by remember { mutableIntStateOf(if (isDarkTheme) 0 else 3) }
|
||||
|
||||
// Auto-switch to matching theme group when app theme changes
|
||||
LaunchedEffect(isDarkTheme) {
|
||||
val currentTheme = qrThemes.getOrNull(selectedThemeIndex)
|
||||
if (currentTheme != null && currentTheme.isDark != isDarkTheme) {
|
||||
// Map to same position in the other group
|
||||
val posInGroup = if (currentTheme.isDark) selectedThemeIndex else selectedThemeIndex - 3
|
||||
selectedThemeIndex = if (isDarkTheme) posInGroup.coerceIn(0, 2) else (posInGroup + 3).coerceIn(3, 5)
|
||||
}
|
||||
}
|
||||
// Local dark/light state — independent from the global app theme
|
||||
var localIsDark by remember { mutableStateOf(isDarkTheme) }
|
||||
|
||||
val theme = qrThemes[selectedThemeIndex]
|
||||
|
||||
@@ -272,7 +264,7 @@ fun MyQrCodeScreen(
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
|
||||
color = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White,
|
||||
color = if (localIsDark) Color(0xFF1C1C1E) else Color.White,
|
||||
shadowElevation = 16.dp
|
||||
) {
|
||||
Column(
|
||||
@@ -299,16 +291,17 @@ fun MyQrCodeScreen(
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(TablerIcons.X, contentDescription = "Close",
|
||||
tint = if (isDarkTheme) Color.White else Color.Black)
|
||||
tint = if (localIsDark) Color.White else Color.Black)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text("QR Code", fontSize = 17.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = if (isDarkTheme) Color.White else Color.Black)
|
||||
color = if (localIsDark) Color.White else Color.Black)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
var themeButtonPos by remember { mutableStateOf(Offset.Zero) }
|
||||
IconButton(
|
||||
onClick = {
|
||||
// Snapshot → toggle theme → circular reveal
|
||||
// Snapshot → toggle LOCAL theme → circular reveal
|
||||
// Does NOT toggle the global app theme
|
||||
val now = System.currentTimeMillis()
|
||||
if (!revealActive && rootSize.width > 0 && now - lastRevealTime >= revealCooldownMs) {
|
||||
lastRevealTime = now
|
||||
@@ -319,11 +312,10 @@ fun MyQrCodeScreen(
|
||||
revealActive = true
|
||||
revealCenter = themeButtonPos
|
||||
revealSnapshot = snapshot.asImageBitmap()
|
||||
// Switch to matching wallpaper in new theme
|
||||
val posInGroup = if (isDarkTheme) selectedThemeIndex else selectedThemeIndex - 3
|
||||
val newIndex = if (isDarkTheme) (posInGroup + 3).coerceIn(3, 5) else posInGroup.coerceIn(0, 2)
|
||||
val posInGroup = if (localIsDark) selectedThemeIndex else selectedThemeIndex - 3
|
||||
val newIndex = if (localIsDark) (posInGroup + 3).coerceIn(3, 5) else posInGroup.coerceIn(0, 2)
|
||||
selectedThemeIndex = newIndex
|
||||
onToggleTheme()
|
||||
localIsDark = !localIsDark
|
||||
scope.launch {
|
||||
try {
|
||||
revealRadius.snapTo(0f)
|
||||
@@ -337,11 +329,8 @@ fun MyQrCodeScreen(
|
||||
revealActive = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// drawToBitmap failed — skip
|
||||
}
|
||||
}
|
||||
// else: cooldown active — ignore tap
|
||||
},
|
||||
modifier = Modifier.onGloballyPositioned { coords ->
|
||||
val pos = coords.positionInRoot()
|
||||
@@ -350,9 +339,9 @@ fun MyQrCodeScreen(
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isDarkTheme) TablerIcons.Sun else TablerIcons.MoonStars,
|
||||
imageVector = if (localIsDark) TablerIcons.Sun else TablerIcons.MoonStars,
|
||||
contentDescription = "Toggle theme",
|
||||
tint = if (isDarkTheme) Color.White else Color.Black
|
||||
tint = if (localIsDark) Color.White else Color.Black
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -360,7 +349,7 @@ fun MyQrCodeScreen(
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Wallpaper selector — show current theme's wallpapers
|
||||
val currentThemes = qrThemes.filter { it.isDark == isDarkTheme }
|
||||
val currentThemes = qrThemes.filter { it.isDark == localIsDark }
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
@@ -394,7 +383,7 @@ fun MyQrCodeScreen(
|
||||
modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||
}
|
||||
Icon(TablerIcons.Scan, contentDescription = null,
|
||||
tint = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.35f),
|
||||
tint = if (localIsDark) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.35f),
|
||||
modifier = Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,10 +43,10 @@ data class AppIconOption(
|
||||
)
|
||||
|
||||
private val iconOptions = listOf(
|
||||
AppIconOption("default", "Rosetta", "Original icon", ".MainActivityDefault", R.drawable.ic_launcher_foreground, Color(0xFF1B1B1B)),
|
||||
AppIconOption("calculator", "Calculator", "Disguise as calculator", ".MainActivityCalculator", R.drawable.ic_calc_foreground, Color(0xFF795548)),
|
||||
AppIconOption("weather", "Weather", "Disguise as weather app", ".MainActivityWeather", R.drawable.ic_weather_foreground, Color(0xFF42A5F5)),
|
||||
AppIconOption("notes", "Notes", "Disguise as notes app", ".MainActivityNotes", R.drawable.ic_notes_foreground, Color(0xFFFFC107))
|
||||
AppIconOption("default", "Rosetta", "Original icon", ".MainActivityDefault", R.drawable.rosetta_icon, Color(0xFF1B1B1B)),
|
||||
AppIconOption("calculator", "Calculator", "Disguise as calculator", ".MainActivityCalculator", R.drawable.ic_calc_foreground, Color.White),
|
||||
AppIconOption("weather", "Weather", "Disguise as weather app", ".MainActivityWeather", R.drawable.ic_weather_foreground, Color.White),
|
||||
AppIconOption("notes", "Notes", "Disguise as notes app", ".MainActivityNotes", R.drawable.ic_notes_foreground, Color.White)
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -173,9 +173,8 @@ fun AppIconScreen(
|
||||
.background(option.previewBg),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// Default icon has 15% inset built-in — show full size
|
||||
val iconSize = if (option.id == "default") 52.dp else 36.dp
|
||||
val scaleType = if (option.id == "default")
|
||||
val imgSize = if (option.id == "default") 52.dp else 44.dp
|
||||
val imgScale = if (option.id == "default")
|
||||
android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
else
|
||||
android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
@@ -183,10 +182,10 @@ fun AppIconScreen(
|
||||
factory = { ctx ->
|
||||
android.widget.ImageView(ctx).apply {
|
||||
setImageResource(option.iconRes)
|
||||
this.scaleType = scaleType
|
||||
scaleType = imgScale
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(iconSize)
|
||||
modifier = Modifier.size(imgSize)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.rosetta.messenger.ui.splash
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.*
|
||||
@@ -64,7 +66,11 @@ fun SplashScreen(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(backgroundColor),
|
||||
.background(backgroundColor)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// Glow effect behind logo
|
||||
|
||||
BIN
app/src/main/res/drawable-xxxhdpi/ic_calc_downloaded.png
Normal file
BIN
app/src/main/res/drawable-xxxhdpi/ic_calc_downloaded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
app/src/main/res/drawable-xxxhdpi/ic_notes_downloaded.png
Normal file
BIN
app/src/main/res/drawable-xxxhdpi/ic_notes_downloaded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
BIN
app/src/main/res/drawable-xxxhdpi/ic_weather_downloaded.png
Normal file
BIN
app/src/main/res/drawable-xxxhdpi/ic_weather_downloaded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#795548"/>
|
||||
<solid android:color="#FFFFFF"/>
|
||||
</shape>
|
||||
|
||||
@@ -1,38 +1,7 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Calculator — Google Calculator style: simple, bold, white on color -->
|
||||
<group android:translateX="30" android:translateY="24">
|
||||
<!-- = sign (equals, large, centered, bold) -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M6,22h36v5H6z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M6,33h36v5H6z" />
|
||||
<!-- + sign (plus, smaller, top right) -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:alpha="0.7"
|
||||
android:pathData="M34,2h4v16h-4z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:alpha="0.7"
|
||||
android:pathData="M28,8h16v4H28z" />
|
||||
<!-- ÷ sign (divide, bottom) -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:alpha="0.7"
|
||||
android:pathData="M6,50h36v4H6z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:alpha="0.7"
|
||||
android:pathData="M22,43a3,3,0,1,1,-3,3a3,3,0,0,1,3,-3z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:alpha="0.7"
|
||||
android:pathData="M22,57a3,3,0,1,1,-3,3a3,3,0,0,1,3,-3z" />
|
||||
</group>
|
||||
</vector>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:inset="20%">
|
||||
<bitmap
|
||||
android:src="@drawable/ic_calc_downloaded"
|
||||
android:gravity="fill"/>
|
||||
</inset>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#FFC107"/>
|
||||
<solid android:color="#FFFFFF"/>
|
||||
</shape>
|
||||
|
||||
@@ -1,52 +1,7 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Notes — Google Keep style: white card with colored pin/accent -->
|
||||
<group android:translateX="26" android:translateY="20">
|
||||
<!-- Card shadow -->
|
||||
<path
|
||||
android:fillColor="#00000020"
|
||||
android:pathData="M5,5h46c2.8,0 5,2.2 5,5v54c0,2.8 -2.2,5 -5,5H5c-2.8,0 -5,-2.2 -5,-5V10C0,7.2 2.2,5 5,5z" />
|
||||
<!-- White card -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M5,2h46c2.8,0 5,2.2 5,5v54c0,2.8 -2.2,5 -5,5H5c-2.8,0 -5,-2.2 -5,-5V7C0,4.2 2.2,2 5,2z" />
|
||||
<!-- Checkbox checked -->
|
||||
<path
|
||||
android:fillColor="#FFA000"
|
||||
android:pathData="M6,14h6v6H6z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M7.5,17.5l1.5,1.5l3.5,-3.5"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="1.5"/>
|
||||
<!-- Text line 1 (next to checkbox) -->
|
||||
<path android:fillColor="#757575" android:pathData="M16,15h32v3H16z" />
|
||||
<!-- Checkbox unchecked -->
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#BDBDBD"
|
||||
android:strokeWidth="1.5"
|
||||
android:pathData="M6,28h6v6H6z" />
|
||||
<!-- Text line 2 -->
|
||||
<path android:fillColor="#BDBDBD" android:pathData="M16,29h28v3H16z" />
|
||||
<!-- Checkbox unchecked -->
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#BDBDBD"
|
||||
android:strokeWidth="1.5"
|
||||
android:pathData="M6,42h6v6H6z" />
|
||||
<!-- Text line 3 -->
|
||||
<path android:fillColor="#BDBDBD" android:pathData="M16,43h22v3H16z" />
|
||||
<!-- Checkbox unchecked -->
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#BDBDBD"
|
||||
android:strokeWidth="1.5"
|
||||
android:pathData="M6,56h6v6H6z" />
|
||||
<!-- Text line 4 -->
|
||||
<path android:fillColor="#BDBDBD" android:pathData="M16,57h18v3H16z" />
|
||||
</group>
|
||||
</vector>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:inset="20%">
|
||||
<bitmap
|
||||
android:src="@drawable/ic_notes_downloaded"
|
||||
android:gravity="fill"/>
|
||||
</inset>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#42A5F5"/>
|
||||
<solid android:color="#FFFFFF"/>
|
||||
</shape>
|
||||
|
||||
@@ -1,32 +1,7 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Weather — sun with cloud, Material You style, centered in safe zone -->
|
||||
<group android:translateX="20" android:translateY="20">
|
||||
<!-- Sun glow (soft circle) -->
|
||||
<path
|
||||
android:fillColor="#FFF9C4"
|
||||
android:pathData="M34,24a14,14,0,1,1,-14,14a14,14,0,0,1,14,-14z" />
|
||||
<!-- Sun core -->
|
||||
<path
|
||||
android:fillColor="#FFB300"
|
||||
android:pathData="M34,28a10,10,0,1,1,-10,10a10,10,0,0,1,10,-10z" />
|
||||
<!-- Sun rays -->
|
||||
<path
|
||||
android:fillColor="#FFB300"
|
||||
android:pathData="M33,12h2v8h-2zM33,48h2v8h-2zM12,37v-2h8v2zM48,37v-2h8v2z" />
|
||||
<path
|
||||
android:fillColor="#FFB300"
|
||||
android:pathData="M18.3,18.3l1.4,1.4l5.7,5.7l-1.4,1.4l-5.7,-5.7zM42.6,42.6l1.4,1.4l5.7,5.7l-1.4,1.4l-5.7,-5.7zM18.3,49.7l5.7,-5.7l1.4,1.4l-5.7,5.7zM42.6,25.4l5.7,-5.7l1.4,1.4l-5.7,5.7z" />
|
||||
<!-- Cloud (in front of sun) -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M52,44c4.4,0 8,3.6 8,8s-3.6,8 -8,8H20c-5.5,0 -10,-4.5 -10,-10s4.5,-10 10,-10c0.5,0 1,0 1.5,0.1C23.2,35.2 28,32 34,32c6.4,0 11.7,4.4 13.2,10.3C49,42.1 50.5,42 52,44z" />
|
||||
<!-- Cloud shadow hint -->
|
||||
<path
|
||||
android:fillColor="#E0E0E0"
|
||||
android:pathData="M20,58h32c2.5,0 4.8,-0.8 6.6,-2H14C16,57 17.9,58 20,58z" />
|
||||
</group>
|
||||
</vector>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:inset="20%">
|
||||
<bitmap
|
||||
android:src="@drawable/ic_weather_downloaded"
|
||||
android:gravity="fill"/>
|
||||
</inset>
|
||||
|
||||
Reference in New Issue
Block a user