fix: Большое количество изменений
This commit is contained in:
@@ -45,6 +45,10 @@ object CryptoManager {
|
|||||||
// ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной
|
// ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной
|
||||||
// расшифровке
|
// расшифровке
|
||||||
private const val DECRYPTION_CACHE_SIZE = 2000
|
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)
|
private val decryptionCache = ConcurrentHashMap<String, String>(DECRYPTION_CACHE_SIZE, 0.75f, 4)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -298,17 +302,21 @@ object CryptoManager {
|
|||||||
* 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений
|
* 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений
|
||||||
*/
|
*/
|
||||||
fun decryptWithPassword(encryptedData: String, password: String): String? {
|
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)
|
// 🚀 ОПТИМИЗАЦИЯ: Lock-free проверка кэша (ConcurrentHashMap)
|
||||||
val cacheKey = "$password:$encryptedData"
|
if (cacheKey != null) {
|
||||||
decryptionCache[cacheKey]?.let {
|
decryptionCache[cacheKey]?.let {
|
||||||
return it
|
return it
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val result = decryptWithPasswordInternal(encryptedData, password)
|
val result = decryptWithPasswordInternal(encryptedData, password)
|
||||||
|
|
||||||
// 🚀 Сохраняем в кэш (lock-free)
|
// 🚀 Сохраняем в кэш (lock-free)
|
||||||
if (result != null) {
|
if (cacheKey != null && result != null && result.length <= MAX_CACHEABLE_DECRYPTED_CHARS) {
|
||||||
// Ограничиваем размер кэша
|
// Ограничиваем размер кэша
|
||||||
if (decryptionCache.size >= DECRYPTION_CACHE_SIZE) {
|
if (decryptionCache.size >= DECRYPTION_CACHE_SIZE) {
|
||||||
// Удаляем ~10% самых старых записей
|
// Удаляем ~10% самых старых записей
|
||||||
|
|||||||
@@ -856,7 +856,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
isOutgoing = fm.isOutgoing,
|
isOutgoing = fm.isOutgoing,
|
||||||
publicKey = fm.senderPublicKey,
|
publicKey = fm.senderPublicKey,
|
||||||
senderName = fm.senderName,
|
senderName = fm.senderName,
|
||||||
attachments = fm.attachments
|
attachments = fm.attachments,
|
||||||
|
chachaKeyPlainHex = fm.chachaKeyPlain
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_isForwardMode.value = true
|
_isForwardMode.value = true
|
||||||
@@ -2160,7 +2161,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
forwardedList.add(ReplyData(
|
forwardedList.add(ReplyData(
|
||||||
messageId = fwdMessageId,
|
messageId = fwdMessageId,
|
||||||
senderName = senderDisplayName,
|
senderName = senderDisplayName,
|
||||||
text = fwdText,
|
text = resolveReplyPreviewText(fwdText, fwdAttachments),
|
||||||
isFromMe = fwdIsFromMe,
|
isFromMe = fwdIsFromMe,
|
||||||
isForwarded = true,
|
isForwarded = true,
|
||||||
forwardedFromName = senderDisplayName,
|
forwardedFromName = senderDisplayName,
|
||||||
@@ -2346,7 +2347,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
ReplyData(
|
ReplyData(
|
||||||
messageId = realMessageId,
|
messageId = realMessageId,
|
||||||
senderName = resolvedSenderName,
|
senderName = resolvedSenderName,
|
||||||
text = replyText,
|
text = resolveReplyPreviewText(replyText, originalAttachments),
|
||||||
isFromMe = isReplyFromMe,
|
isFromMe = isReplyFromMe,
|
||||||
isForwarded = isForwarded,
|
isForwarded = isForwarded,
|
||||||
forwardedFromName = forwardFromDisplay,
|
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 отправителя для
|
* 🔥 Установить сообщения для Reply (как в React Native) Сохраняем publicKey отправителя для
|
||||||
* правильного отображения цитаты
|
* правильного отображения цитаты
|
||||||
@@ -2515,16 +2528,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
msg.senderPublicKey.trim().ifEmpty {
|
msg.senderPublicKey.trim().ifEmpty {
|
||||||
if (msg.isOutgoing) sender else opponent
|
if (msg.isOutgoing) sender else opponent
|
||||||
}
|
}
|
||||||
|
val resolvedAttachments =
|
||||||
|
msg.attachments
|
||||||
|
.filter { it.type != AttachmentType.MESSAGES }
|
||||||
ReplyMessage(
|
ReplyMessage(
|
||||||
messageId = msg.id,
|
messageId = msg.id,
|
||||||
text = msg.text,
|
text = resolveReplyPreviewText(msg.text, resolvedAttachments),
|
||||||
timestamp = msg.timestamp.time,
|
timestamp = msg.timestamp.time,
|
||||||
isOutgoing = msg.isOutgoing,
|
isOutgoing = msg.isOutgoing,
|
||||||
publicKey = resolvedPublicKey,
|
publicKey = resolvedPublicKey,
|
||||||
senderName = msg.senderName,
|
senderName = msg.senderName,
|
||||||
attachments =
|
attachments = resolvedAttachments,
|
||||||
msg.attachments
|
|
||||||
.filter { it.type != AttachmentType.MESSAGES },
|
|
||||||
chachaKeyPlainHex = msg.chachaKeyPlainHex
|
chachaKeyPlainHex = msg.chachaKeyPlainHex
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2542,16 +2556,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
msg.senderPublicKey.trim().ifEmpty {
|
msg.senderPublicKey.trim().ifEmpty {
|
||||||
if (msg.isOutgoing) sender else opponent
|
if (msg.isOutgoing) sender else opponent
|
||||||
}
|
}
|
||||||
|
val resolvedAttachments =
|
||||||
|
msg.attachments
|
||||||
|
.filter { it.type != AttachmentType.MESSAGES }
|
||||||
ReplyMessage(
|
ReplyMessage(
|
||||||
messageId = msg.id,
|
messageId = msg.id,
|
||||||
text = msg.text,
|
text = resolveReplyPreviewText(msg.text, resolvedAttachments),
|
||||||
timestamp = msg.timestamp.time,
|
timestamp = msg.timestamp.time,
|
||||||
isOutgoing = msg.isOutgoing,
|
isOutgoing = msg.isOutgoing,
|
||||||
publicKey = resolvedPublicKey,
|
publicKey = resolvedPublicKey,
|
||||||
senderName = msg.senderName,
|
senderName = msg.senderName,
|
||||||
attachments =
|
attachments = resolvedAttachments,
|
||||||
msg.attachments
|
chachaKeyPlainHex = msg.chachaKeyPlainHex
|
||||||
.filter { it.type != AttachmentType.MESSAGES }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_isForwardMode.value = true
|
_isForwardMode.value = true
|
||||||
@@ -2942,7 +2958,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
ReplyData(
|
ReplyData(
|
||||||
messageId = firstReply.messageId,
|
messageId = firstReply.messageId,
|
||||||
senderName = firstReplySenderName,
|
senderName = firstReplySenderName,
|
||||||
text = firstReply.text,
|
text = resolveReplyPreviewText(firstReply.text, replyAttachments),
|
||||||
isFromMe = firstReply.isOutgoing,
|
isFromMe = firstReply.isOutgoing,
|
||||||
isForwarded = isForward,
|
isForwarded = isForward,
|
||||||
forwardedFromName =
|
forwardedFromName =
|
||||||
@@ -2972,7 +2988,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
ReplyData(
|
ReplyData(
|
||||||
messageId = msg.messageId,
|
messageId = msg.messageId,
|
||||||
senderName = senderDisplayName,
|
senderName = senderDisplayName,
|
||||||
text = msg.text,
|
text = resolveReplyPreviewText(msg.text, resolvedAttachments),
|
||||||
isFromMe = msg.isOutgoing,
|
isFromMe = msg.isOutgoing,
|
||||||
isForwarded = true,
|
isForwarded = true,
|
||||||
forwardedFromName = senderDisplayName,
|
forwardedFromName = senderDisplayName,
|
||||||
@@ -3143,6 +3159,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
if (isForwardToSend) {
|
if (isForwardToSend) {
|
||||||
put("forwarded", true)
|
put("forwarded", true)
|
||||||
put("senderName", msg.senderName)
|
put("senderName", msg.senderName)
|
||||||
|
if (msg.chachaKeyPlainHex.isNotEmpty()) {
|
||||||
|
put("chacha_key_plain", msg.chachaKeyPlainHex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
replyJsonArray.put(replyJson)
|
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.calls.CallTopBanner
|
||||||
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
|
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
|
||||||
import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner
|
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.AppleEmojiText
|
||||||
import com.rosetta.messenger.ui.components.AvatarImage
|
import com.rosetta.messenger.ui.components.AvatarImage
|
||||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
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) }
|
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 {
|
private fun shortPublicKey(value: String): String {
|
||||||
val trimmed = value.trim()
|
val trimmed = value.trim()
|
||||||
if (trimmed.length <= 12) return trimmed
|
if (trimmed.length <= 12) return trimmed
|
||||||
@@ -467,6 +480,12 @@ fun ChatsListScreen(
|
|||||||
// <20>🔥 Пользователи, которые сейчас печатают
|
// <20>🔥 Пользователи, которые сейчас печатают
|
||||||
val typingUsers by ProtocolManager.typingUsers.collectAsState()
|
val typingUsers by ProtocolManager.typingUsers.collectAsState()
|
||||||
val typingUsersByDialogSnapshot by ProtocolManager.typingUsersByDialogSnapshot.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
|
// Load dialogs when account is available
|
||||||
LaunchedEffect(accountPublicKey, accountPrivateKey) {
|
LaunchedEffect(accountPublicKey, accountPrivateKey) {
|
||||||
@@ -2130,6 +2149,50 @@ fun ChatsListScreen(
|
|||||||
callUiState.phase != CallPhase.INCOMING
|
callUiState.phase != CallPhase.INCOMING
|
||||||
}
|
}
|
||||||
val callBannerHeight = 40.dp
|
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 для
|
// 🔥 Берем dialogs из chatsState для
|
||||||
// консистентности
|
// консистентности
|
||||||
// 📌 Порядок по времени готовится в ViewModel.
|
// 📌 Порядок по времени готовится в ViewModel.
|
||||||
@@ -2332,9 +2395,7 @@ fun ChatsListScreen(
|
|||||||
Modifier.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
.padding(
|
.padding(
|
||||||
top =
|
top =
|
||||||
if (showStickyCallBanner)
|
stickyTopInset
|
||||||
callBannerHeight
|
|
||||||
else 0.dp
|
|
||||||
)
|
)
|
||||||
.then(
|
.then(
|
||||||
if (requestsCount > 0) Modifier.nestedScroll(requestsNestedScroll)
|
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 =
|
val isSelectedDialog =
|
||||||
selectedChatKeys
|
selectedChatKeys
|
||||||
.contains(
|
.contains(
|
||||||
@@ -2613,6 +2686,8 @@ fun ChatsListScreen(
|
|||||||
typingDisplayName,
|
typingDisplayName,
|
||||||
typingSenderPublicKey =
|
typingSenderPublicKey =
|
||||||
typingSenderPublicKey,
|
typingSenderPublicKey,
|
||||||
|
isVoicePlaybackActive =
|
||||||
|
isVoicePlaybackActive,
|
||||||
isBlocked =
|
isBlocked =
|
||||||
isBlocked,
|
isBlocked,
|
||||||
isSavedMessages =
|
isSavedMessages =
|
||||||
@@ -2746,14 +2821,41 @@ fun ChatsListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (showStickyCallBanner) {
|
if (showStickyCallBanner || showVoiceMiniPlayer) {
|
||||||
CallTopBanner(
|
Column(
|
||||||
state = callUiState,
|
modifier =
|
||||||
isSticky = true,
|
Modifier.fillMaxWidth()
|
||||||
isDarkTheme = isDarkTheme,
|
.align(
|
||||||
avatarRepository = avatarRepository,
|
Alignment.TopCenter
|
||||||
onOpenCall = onOpenCallOverlay
|
)
|
||||||
)
|
) {
|
||||||
|
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,
|
isTyping: Boolean = false,
|
||||||
typingDisplayName: String = "",
|
typingDisplayName: String = "",
|
||||||
typingSenderPublicKey: String = "",
|
typingSenderPublicKey: String = "",
|
||||||
|
isVoicePlaybackActive: Boolean = false,
|
||||||
isBlocked: Boolean = false,
|
isBlocked: Boolean = false,
|
||||||
isGroupChat: Boolean = false,
|
isGroupChat: Boolean = false,
|
||||||
isSavedMessages: Boolean = false,
|
isSavedMessages: Boolean = false,
|
||||||
@@ -4125,6 +4228,7 @@ fun SwipeableDialogItem(
|
|||||||
isTyping = isTyping,
|
isTyping = isTyping,
|
||||||
typingDisplayName = typingDisplayName,
|
typingDisplayName = typingDisplayName,
|
||||||
typingSenderPublicKey = typingSenderPublicKey,
|
typingSenderPublicKey = typingSenderPublicKey,
|
||||||
|
isVoicePlaybackActive = isVoicePlaybackActive,
|
||||||
isPinned = isPinned,
|
isPinned = isPinned,
|
||||||
isBlocked = isBlocked,
|
isBlocked = isBlocked,
|
||||||
isMuted = isMuted,
|
isMuted = isMuted,
|
||||||
@@ -4144,6 +4248,7 @@ fun DialogItemContent(
|
|||||||
isTyping: Boolean = false,
|
isTyping: Boolean = false,
|
||||||
typingDisplayName: String = "",
|
typingDisplayName: String = "",
|
||||||
typingSenderPublicKey: String = "",
|
typingSenderPublicKey: String = "",
|
||||||
|
isVoicePlaybackActive: Boolean = false,
|
||||||
isPinned: Boolean = false,
|
isPinned: Boolean = false,
|
||||||
isBlocked: Boolean = false,
|
isBlocked: Boolean = false,
|
||||||
isMuted: Boolean = false,
|
isMuted: Boolean = false,
|
||||||
@@ -4278,12 +4383,12 @@ fun DialogItemContent(
|
|||||||
// Name and last message
|
// Name and last message
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth().heightIn(min = 22.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f).heightIn(min = 22.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
AppleEmojiText(
|
AppleEmojiText(
|
||||||
@@ -4293,7 +4398,8 @@ fun DialogItemContent(
|
|||||||
color = textColor,
|
color = textColor,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = android.text.TextUtils.TruncateAt.END,
|
overflow = android.text.TextUtils.TruncateAt.END,
|
||||||
enableLinks = false
|
enableLinks = false,
|
||||||
|
minHeightMultiplier = 1f
|
||||||
)
|
)
|
||||||
if (isGroupDialog) {
|
if (isGroupDialog) {
|
||||||
Spacer(modifier = Modifier.width(5.dp))
|
Spacer(modifier = Modifier.width(5.dp))
|
||||||
@@ -4301,7 +4407,7 @@ fun DialogItemContent(
|
|||||||
imageVector = TablerIcons.Users,
|
imageVector = TablerIcons.Users,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = secondaryTextColor.copy(alpha = 0.9f),
|
tint = secondaryTextColor.copy(alpha = 0.9f),
|
||||||
modifier = Modifier.size(15.dp)
|
modifier = Modifier.size(14.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val isOfficialByKey = MessageRepository.isSystemAccount(dialog.opponentKey)
|
val isOfficialByKey = MessageRepository.isSystemAccount(dialog.opponentKey)
|
||||||
@@ -4310,7 +4416,7 @@ fun DialogItemContent(
|
|||||||
VerifiedBadge(
|
VerifiedBadge(
|
||||||
verified = if (dialog.verified > 0) dialog.verified else 1,
|
verified = if (dialog.verified > 0) dialog.verified else 1,
|
||||||
size = 16,
|
size = 16,
|
||||||
modifier = Modifier.offset(y = (-2).dp),
|
modifier = Modifier,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
badgeTint = PrimaryBlue
|
badgeTint = PrimaryBlue
|
||||||
)
|
)
|
||||||
@@ -4337,6 +4443,7 @@ fun DialogItemContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
|
modifier = Modifier.heightIn(min = 16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.End
|
horizontalArrangement = Arrangement.End
|
||||||
) {
|
) {
|
||||||
@@ -4467,7 +4574,7 @@ fun DialogItemContent(
|
|||||||
0.6f
|
0.6f
|
||||||
),
|
),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.size(14.dp)
|
Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
Spacer(
|
Spacer(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -4487,9 +4594,11 @@ fun DialogItemContent(
|
|||||||
Text(
|
Text(
|
||||||
text = formattedTime,
|
text = formattedTime,
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
|
lineHeight = 13.sp,
|
||||||
color =
|
color =
|
||||||
if (visibleUnreadCount > 0) PrimaryBlue
|
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),
|
modifier = Modifier.weight(1f).heightIn(min = 20.dp),
|
||||||
contentAlignment = Alignment.CenterStart
|
contentAlignment = Alignment.CenterStart
|
||||||
) {
|
) {
|
||||||
|
val subtitleMode =
|
||||||
|
remember(
|
||||||
|
isVoicePlaybackActive,
|
||||||
|
isTyping,
|
||||||
|
dialog.draftText
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
isVoicePlaybackActive -> "voice"
|
||||||
|
isTyping -> "typing"
|
||||||
|
!dialog.draftText.isNullOrEmpty() -> "draft"
|
||||||
|
else -> "message"
|
||||||
|
}
|
||||||
|
}
|
||||||
Crossfade(
|
Crossfade(
|
||||||
targetState = isTyping,
|
targetState = subtitleMode,
|
||||||
animationSpec = tween(150),
|
animationSpec = tween(150),
|
||||||
label = "chatSubtitle"
|
label = "chatSubtitle"
|
||||||
) { showTyping ->
|
) { mode ->
|
||||||
if (showTyping) {
|
if (mode == "voice") {
|
||||||
|
VoicePlaybackIndicatorSmall(
|
||||||
|
isDarkTheme = isDarkTheme
|
||||||
|
)
|
||||||
|
} else if (mode == "typing") {
|
||||||
TypingIndicatorSmall(
|
TypingIndicatorSmall(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
typingDisplayName = typingDisplayName,
|
typingDisplayName = typingDisplayName,
|
||||||
typingSenderPublicKey = typingSenderPublicKey
|
typingSenderPublicKey = typingSenderPublicKey
|
||||||
)
|
)
|
||||||
} else if (!dialog.draftText.isNullOrEmpty()) {
|
} else if (mode == "draft") {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
text = "Draft: ",
|
text = "Draft: ",
|
||||||
@@ -4527,7 +4653,7 @@ fun DialogItemContent(
|
|||||||
maxLines = 1
|
maxLines = 1
|
||||||
)
|
)
|
||||||
AppleEmojiText(
|
AppleEmojiText(
|
||||||
text = dialog.draftText,
|
text = dialog.draftText.orEmpty(),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = secondaryTextColor,
|
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
|
@Composable
|
||||||
private fun SwipeBackContainer(
|
private fun SwipeBackContainer(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
@@ -5467,7 +5748,7 @@ fun DrawerMenuItemEnhanced(
|
|||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Medium,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
@@ -5527,7 +5808,7 @@ fun DrawerMenuItemEnhanced(
|
|||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Medium,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
@@ -5561,7 +5842,7 @@ fun DrawerMenuItemEnhanced(
|
|||||||
fun DrawerDivider(isDarkTheme: Boolean) {
|
fun DrawerDivider(isDarkTheme: Boolean) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Divider(
|
Divider(
|
||||||
modifier = Modifier.padding(horizontal = 20.dp),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFC8C8CC),
|
color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFC8C8CC),
|
||||||
thickness = 0.5.dp
|
thickness = 0.5.dp
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -573,23 +573,23 @@ private fun ChatsTabContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Recent header (always show with Clear All) ───
|
// ─── Recent header (only when there are recents) ───
|
||||||
item {
|
if (recentUsers.isNotEmpty()) {
|
||||||
Row(
|
item {
|
||||||
modifier = Modifier
|
Row(
|
||||||
.fillMaxWidth()
|
modifier = Modifier
|
||||||
.padding(horizontal = 16.dp)
|
.fillMaxWidth()
|
||||||
.padding(top = 14.dp, bottom = 6.dp),
|
.padding(horizontal = 16.dp)
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
.padding(top = 14.dp, bottom = 6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
verticalAlignment = Alignment.CenterVertically
|
||||||
Text(
|
) {
|
||||||
"Recent",
|
Text(
|
||||||
fontSize = 15.sp,
|
"Recent",
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontSize = 15.sp,
|
||||||
color = PrimaryBlue
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
color = PrimaryBlue
|
||||||
if (recentUsers.isNotEmpty()) {
|
)
|
||||||
Text(
|
Text(
|
||||||
"Clear All",
|
"Clear All",
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.graphics.Matrix
|
|||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
import android.media.MediaPlayer
|
import android.media.MediaPlayer
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.util.LruCache
|
import android.util.LruCache
|
||||||
@@ -91,6 +92,8 @@ import kotlinx.coroutines.withContext
|
|||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
@@ -153,25 +156,48 @@ private fun decodeVoicePayload(data: String): ByteArray? {
|
|||||||
return decodeHexPayload(data) ?: decodeBase64Payload(data)
|
return decodeHexPayload(data) ?: decodeBase64Payload(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private object VoicePlaybackCoordinator {
|
object VoicePlaybackCoordinator {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
private val speedSteps = listOf(1f, 1.5f, 2f)
|
||||||
private var player: MediaPlayer? = null
|
private var player: MediaPlayer? = null
|
||||||
private var currentAttachmentId: String? = null
|
private var currentAttachmentId: String? = null
|
||||||
private var progressJob: Job? = null
|
private var progressJob: Job? = null
|
||||||
private val _playingAttachmentId = MutableStateFlow<String?>(null)
|
private val _playingAttachmentId = MutableStateFlow<String?>(null)
|
||||||
val playingAttachmentId: StateFlow<String?> = _playingAttachmentId.asStateFlow()
|
val playingAttachmentId: StateFlow<String?> = _playingAttachmentId.asStateFlow()
|
||||||
|
private val _playingDialogKey = MutableStateFlow<String?>(null)
|
||||||
|
val playingDialogKey: StateFlow<String?> = _playingDialogKey.asStateFlow()
|
||||||
private val _positionMs = MutableStateFlow(0)
|
private val _positionMs = MutableStateFlow(0)
|
||||||
val positionMs: StateFlow<Int> = _positionMs.asStateFlow()
|
val positionMs: StateFlow<Int> = _positionMs.asStateFlow()
|
||||||
private val _durationMs = MutableStateFlow(0)
|
private val _durationMs = MutableStateFlow(0)
|
||||||
val durationMs: StateFlow<Int> = _durationMs.asStateFlow()
|
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()) {
|
if (!sourceFile.exists()) {
|
||||||
onError("Voice file is missing")
|
onError("Voice file is missing")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (currentAttachmentId == attachmentId && player?.isPlaying == true) {
|
|
||||||
stop()
|
if (currentAttachmentId == attachmentId && player != null) {
|
||||||
|
if (_isPlaying.value) {
|
||||||
|
pause()
|
||||||
|
} else {
|
||||||
|
resume(onError = onError)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,22 +213,18 @@ private object VoicePlaybackCoordinator {
|
|||||||
mediaPlayer.setDataSource(sourceFile.absolutePath)
|
mediaPlayer.setDataSource(sourceFile.absolutePath)
|
||||||
mediaPlayer.setOnCompletionListener { stop() }
|
mediaPlayer.setOnCompletionListener { stop() }
|
||||||
mediaPlayer.prepare()
|
mediaPlayer.prepare()
|
||||||
|
applyPlaybackSpeed(mediaPlayer)
|
||||||
mediaPlayer.start()
|
mediaPlayer.start()
|
||||||
player = mediaPlayer
|
player = mediaPlayer
|
||||||
currentAttachmentId = attachmentId
|
currentAttachmentId = attachmentId
|
||||||
_playingAttachmentId.value = attachmentId
|
_playingAttachmentId.value = attachmentId
|
||||||
|
_playingDialogKey.value = dialogKey.trim().ifBlank { null }
|
||||||
|
_playingSenderLabel.value = senderLabel.trim()
|
||||||
|
_playingTimeLabel.value = playedAtLabel.trim()
|
||||||
_durationMs.value = mediaPlayer.duration.coerceAtLeast(0)
|
_durationMs.value = mediaPlayer.duration.coerceAtLeast(0)
|
||||||
_positionMs.value = mediaPlayer.currentPosition.coerceAtLeast(0)
|
_positionMs.value = mediaPlayer.currentPosition.coerceAtLeast(0)
|
||||||
progressJob?.cancel()
|
_isPlaying.value = true
|
||||||
progressJob =
|
startProgressUpdates(attachmentId)
|
||||||
scope.launch {
|
|
||||||
while (isActive && currentAttachmentId == attachmentId) {
|
|
||||||
val active = player
|
|
||||||
if (active == null || !active.isPlaying) break
|
|
||||||
_positionMs.value = active.currentPosition.coerceAtLeast(0)
|
|
||||||
delay(120)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
runCatching { mediaPlayer.release() }
|
runCatching { mediaPlayer.release() }
|
||||||
stop()
|
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() {
|
fun stop() {
|
||||||
val active = player
|
val active = player
|
||||||
player = null
|
player = null
|
||||||
@@ -217,8 +313,12 @@ private object VoicePlaybackCoordinator {
|
|||||||
progressJob?.cancel()
|
progressJob?.cancel()
|
||||||
progressJob = null
|
progressJob = null
|
||||||
_playingAttachmentId.value = null
|
_playingAttachmentId.value = null
|
||||||
|
_playingDialogKey.value = null
|
||||||
|
_playingSenderLabel.value = ""
|
||||||
|
_playingTimeLabel.value = ""
|
||||||
_positionMs.value = 0
|
_positionMs.value = 0
|
||||||
_durationMs.value = 0
|
_durationMs.value = 0
|
||||||
|
_isPlaying.value = false
|
||||||
if (active != null) {
|
if (active != null) {
|
||||||
runCatching {
|
runCatching {
|
||||||
if (active.isPlaying) active.stop()
|
if (active.isPlaying) active.stop()
|
||||||
@@ -593,6 +693,7 @@ fun MessageAttachments(
|
|||||||
isOutgoing: Boolean,
|
isOutgoing: Boolean,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
senderPublicKey: String,
|
senderPublicKey: String,
|
||||||
|
senderDisplayName: String = "",
|
||||||
dialogPublicKey: String = "",
|
dialogPublicKey: String = "",
|
||||||
isGroupChat: Boolean = false,
|
isGroupChat: Boolean = false,
|
||||||
timestamp: java.util.Date,
|
timestamp: java.util.Date,
|
||||||
@@ -683,6 +784,8 @@ fun MessageAttachments(
|
|||||||
chachaKeyPlainHex = chachaKeyPlainHex,
|
chachaKeyPlainHex = chachaKeyPlainHex,
|
||||||
privateKey = privateKey,
|
privateKey = privateKey,
|
||||||
senderPublicKey = senderPublicKey,
|
senderPublicKey = senderPublicKey,
|
||||||
|
senderDisplayName = senderDisplayName,
|
||||||
|
dialogPublicKey = dialogPublicKey,
|
||||||
isOutgoing = isOutgoing,
|
isOutgoing = isOutgoing,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
@@ -2036,6 +2139,8 @@ private fun VoiceAttachment(
|
|||||||
chachaKeyPlainHex: String,
|
chachaKeyPlainHex: String,
|
||||||
privateKey: String,
|
privateKey: String,
|
||||||
senderPublicKey: String,
|
senderPublicKey: String,
|
||||||
|
senderDisplayName: String,
|
||||||
|
dialogPublicKey: String,
|
||||||
isOutgoing: Boolean,
|
isOutgoing: Boolean,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
timestamp: java.util.Date,
|
timestamp: java.util.Date,
|
||||||
@@ -2043,10 +2148,12 @@ private fun VoiceAttachment(
|
|||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val playingAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
|
val activeAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
|
||||||
val playbackPositionMs by VoicePlaybackCoordinator.positionMs.collectAsState()
|
val playbackPositionMs by VoicePlaybackCoordinator.positionMs.collectAsState()
|
||||||
val playbackDurationMs by VoicePlaybackCoordinator.durationMs.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) =
|
val (previewDurationSecRaw, previewWavesRaw) =
|
||||||
remember(attachment.preview) { parseVoicePreview(attachment.preview) }
|
remember(attachment.preview) { parseVoicePreview(attachment.preview) }
|
||||||
@@ -2078,21 +2185,37 @@ private fun VoiceAttachment(
|
|||||||
val effectiveDurationSec =
|
val effectiveDurationSec =
|
||||||
remember(isPlaying, playbackDurationMs, previewDurationSec) {
|
remember(isPlaying, playbackDurationMs, previewDurationSec) {
|
||||||
val fromPlayer = (playbackDurationMs / 1000).coerceAtLeast(0)
|
val fromPlayer = (playbackDurationMs / 1000).coerceAtLeast(0)
|
||||||
if (isPlaying && fromPlayer > 0) fromPlayer else previewDurationSec
|
if (isActiveTrack && fromPlayer > 0) fromPlayer else previewDurationSec
|
||||||
}
|
}
|
||||||
val progress =
|
val progress =
|
||||||
if (isPlaying && playbackDurationMs > 0) {
|
if (isActiveTrack && playbackDurationMs > 0) {
|
||||||
(playbackPositionMs.toFloat() / playbackDurationMs.toFloat()).coerceIn(0f, 1f)
|
(playbackPositionMs.toFloat() / playbackDurationMs.toFloat()).coerceIn(0f, 1f)
|
||||||
} else {
|
} else {
|
||||||
0f
|
0f
|
||||||
}
|
}
|
||||||
val timeText =
|
val timeText =
|
||||||
if (isPlaying && playbackDurationMs > 0) {
|
if (isActiveTrack && playbackDurationMs > 0) {
|
||||||
val leftSec = ((playbackDurationMs - playbackPositionMs).coerceAtLeast(0) / 1000)
|
val leftSec = ((playbackDurationMs - playbackPositionMs).coerceAtLeast(0) / 1000)
|
||||||
"-${formatVoiceDuration(leftSec)}"
|
formatVoiceDuration(leftSec)
|
||||||
} else {
|
} else {
|
||||||
formatVoiceDuration(effectiveDurationSec)
|
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) {
|
LaunchedEffect(payload, attachment.id) {
|
||||||
if (payload.isBlank()) return@LaunchedEffect
|
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@{
|
val triggerDownload: () -> Unit = download@{
|
||||||
if (attachment.transportTag.isBlank()) {
|
if (attachment.transportTag.isBlank()) {
|
||||||
downloadStatus = DownloadStatus.ERROR
|
downloadStatus = DownloadStatus.ERROR
|
||||||
@@ -2172,9 +2287,15 @@ private fun VoiceAttachment(
|
|||||||
if (file == null || !file.exists()) {
|
if (file == null || !file.exists()) {
|
||||||
if (payload.isNotBlank()) {
|
if (payload.isNotBlank()) {
|
||||||
val prepared = ensureVoiceAudioFile(context, attachment.id, payload)
|
val prepared = ensureVoiceAudioFile(context, attachment.id, payload)
|
||||||
if (prepared != null) {
|
if (prepared != null) {
|
||||||
audioFilePath = prepared.absolutePath
|
audioFilePath = prepared.absolutePath
|
||||||
VoicePlaybackCoordinator.toggle(attachment.id, prepared) { message ->
|
VoicePlaybackCoordinator.toggle(
|
||||||
|
attachmentId = attachment.id,
|
||||||
|
sourceFile = prepared,
|
||||||
|
dialogKey = dialogPublicKey,
|
||||||
|
senderLabel = playbackSenderLabel,
|
||||||
|
playedAtLabel = playbackTimeLabel
|
||||||
|
) { message ->
|
||||||
downloadStatus = DownloadStatus.ERROR
|
downloadStatus = DownloadStatus.ERROR
|
||||||
errorText = message
|
errorText = message
|
||||||
}
|
}
|
||||||
@@ -2186,7 +2307,13 @@ private fun VoiceAttachment(
|
|||||||
triggerDownload()
|
triggerDownload()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
VoicePlaybackCoordinator.toggle(attachment.id, file) { message ->
|
VoicePlaybackCoordinator.toggle(
|
||||||
|
attachmentId = attachment.id,
|
||||||
|
sourceFile = file,
|
||||||
|
dialogKey = dialogPublicKey,
|
||||||
|
senderLabel = playbackSenderLabel,
|
||||||
|
playedAtLabel = playbackTimeLabel
|
||||||
|
) { message ->
|
||||||
downloadStatus = DownloadStatus.ERROR
|
downloadStatus = DownloadStatus.ERROR
|
||||||
errorText = message
|
errorText = message
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,6 +355,16 @@ fun MessageBubble(
|
|||||||
onGroupInviteOpen: (SearchUser) -> Unit = {},
|
onGroupInviteOpen: (SearchUser) -> Unit = {},
|
||||||
contextMenuContent: @Composable () -> Unit = {}
|
contextMenuContent: @Composable () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
|
val isTextSelectionOnThisMessage =
|
||||||
|
remember(
|
||||||
|
textSelectionHelper?.isInSelectionMode,
|
||||||
|
textSelectionHelper?.selectedMessageId,
|
||||||
|
message.id
|
||||||
|
) {
|
||||||
|
textSelectionHelper?.isInSelectionMode == true &&
|
||||||
|
textSelectionHelper.selectedMessageId == message.id
|
||||||
|
}
|
||||||
|
|
||||||
// Swipe-to-reply state
|
// Swipe-to-reply state
|
||||||
val hapticFeedback = LocalHapticFeedback.current
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
var swipeOffset by remember { mutableStateOf(0f) }
|
var swipeOffset by remember { mutableStateOf(0f) }
|
||||||
@@ -374,7 +384,7 @@ fun MessageBubble(
|
|||||||
// Selection animations
|
// Selection animations
|
||||||
val selectionAlpha by
|
val selectionAlpha by
|
||||||
animateFloatAsState(
|
animateFloatAsState(
|
||||||
targetValue = if (isSelected) 0.85f else 1f,
|
targetValue = if (isSelected && !isTextSelectionOnThisMessage) 0.85f else 1f,
|
||||||
animationSpec = tween(150),
|
animationSpec = tween(150),
|
||||||
label = "selectionAlpha"
|
label = "selectionAlpha"
|
||||||
)
|
)
|
||||||
@@ -558,7 +568,8 @@ fun MessageBubble(
|
|||||||
val selectionBackgroundColor by
|
val selectionBackgroundColor by
|
||||||
animateColorAsState(
|
animateColorAsState(
|
||||||
targetValue =
|
targetValue =
|
||||||
if (isSelected) PrimaryBlue.copy(alpha = 0.15f)
|
if (isSelected && !isTextSelectionOnThisMessage)
|
||||||
|
PrimaryBlue.copy(alpha = 0.15f)
|
||||||
else Color.Transparent,
|
else Color.Transparent,
|
||||||
animationSpec = tween(200),
|
animationSpec = tween(200),
|
||||||
label = "selectionBg"
|
label = "selectionBg"
|
||||||
@@ -1004,6 +1015,7 @@ fun MessageBubble(
|
|||||||
isOutgoing = message.isOutgoing,
|
isOutgoing = message.isOutgoing,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
senderPublicKey = senderPublicKey,
|
senderPublicKey = senderPublicKey,
|
||||||
|
senderDisplayName = senderName,
|
||||||
dialogPublicKey = dialogPublicKey,
|
dialogPublicKey = dialogPublicKey,
|
||||||
isGroupChat = isGroupChat,
|
isGroupChat = isGroupChat,
|
||||||
timestamp = message.timestamp,
|
timestamp = message.timestamp,
|
||||||
@@ -2381,6 +2393,8 @@ fun ReplyBubble(
|
|||||||
)
|
)
|
||||||
} else if (!hasImage) {
|
} else if (!hasImage) {
|
||||||
val displayText = when {
|
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.FILE } -> "File"
|
||||||
replyData.attachments.any { it.type == AttachmentType.CALL } -> "Call"
|
replyData.attachments.any { it.type == AttachmentType.CALL } -> "Call"
|
||||||
else -> "..."
|
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.compose.ui.unit.dp
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -160,7 +163,7 @@ fun SwipeBackContainer(
|
|||||||
// Alpha animation for fade-in entry
|
// Alpha animation for fade-in entry
|
||||||
val alphaAnimatable = remember { Animatable(0f) }
|
val alphaAnimatable = remember { Animatable(0f) }
|
||||||
|
|
||||||
// Drag state - direct update without animation
|
// Drag state
|
||||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||||
var isDragging by remember { mutableStateOf(false) }
|
var isDragging by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -177,6 +180,7 @@ fun SwipeBackContainer(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
|
val lifecycleOwner = view.findViewTreeLifecycleOwner()
|
||||||
val dismissKeyboard: () -> Unit = {
|
val dismissKeyboard: () -> Unit = {
|
||||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
@@ -187,21 +191,16 @@ fun SwipeBackContainer(
|
|||||||
focusManager.clearFocus(force = true)
|
focusManager.clearFocus(force = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current offset: use drag offset during drag, animatable otherwise + optional enter slide
|
fun computeCurrentOffset(): Float {
|
||||||
val baseOffset = if (isDragging) dragOffset else offsetAnimatable.value
|
val base = if (isDragging) dragOffset else offsetAnimatable.value
|
||||||
val enterOffset =
|
val enter = if (isAnimatingIn && enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
|
||||||
if (isAnimatingIn && enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
|
enterOffsetAnimatable.value
|
||||||
enterOffsetAnimatable.value
|
} else 0f
|
||||||
} else {
|
return base + enter
|
||||||
0f
|
}
|
||||||
}
|
|
||||||
val currentOffset = baseOffset + enterOffset
|
|
||||||
|
|
||||||
// Current alpha: use animatable during fade animations, otherwise 1
|
// Current alpha: use animatable during fade animations, otherwise 1
|
||||||
val currentAlpha = if (isAnimatingIn || isAnimatingOut) alphaAnimatable.value else 1f
|
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 sharedOwnerId = SwipeBackSharedProgress.ownerId
|
||||||
val sharedOwnerLayer = SwipeBackSharedProgress.ownerLayer
|
val sharedOwnerLayer = SwipeBackSharedProgress.ownerLayer
|
||||||
val sharedProgress = SwipeBackSharedProgress.progress
|
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
|
// Handle visibility changes
|
||||||
// 🔥 FIX: try/finally ensures animation flags are ALWAYS reset even if
|
// 🔥 FIX: try/finally ensures animation flags are ALWAYS reset even if
|
||||||
// LaunchedEffect is cancelled by rapid isVisible changes (fast swipes).
|
// 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
|
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(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxSize().graphicsLayer {
|
Modifier.fillMaxSize().graphicsLayer {
|
||||||
@@ -346,13 +384,15 @@ fun SwipeBackContainer(
|
|||||||
var totalDragY = 0f
|
var totalDragY = 0f
|
||||||
var passedSlop = false
|
var passedSlop = false
|
||||||
var keyboardHiddenForGesture = false
|
var keyboardHiddenForGesture = false
|
||||||
|
var resetOnFinally = true
|
||||||
|
|
||||||
// deferToChildren=true: pre-slop uses Main pass so children
|
// deferToChildren=true: pre-slop uses Main pass so children
|
||||||
// (e.g. LazyRow) process first — if they consume, we back off.
|
// (e.g. LazyRow) process first — if they consume, we back off.
|
||||||
// deferToChildren=false (default): always use Initial pass
|
// deferToChildren=false (default): always use Initial pass
|
||||||
// to intercept before children (original behavior).
|
// to intercept before children (original behavior).
|
||||||
// Post-claim: always Initial to block children.
|
// Post-claim: always Initial to block children.
|
||||||
while (true) {
|
try {
|
||||||
|
while (true) {
|
||||||
val pass =
|
val pass =
|
||||||
if (startedSwipe || !deferToChildren)
|
if (startedSwipe || !deferToChildren)
|
||||||
PointerEventPass.Initial
|
PointerEventPass.Initial
|
||||||
@@ -365,6 +405,7 @@ fun SwipeBackContainer(
|
|||||||
?: break
|
?: break
|
||||||
|
|
||||||
if (change.changedToUpIgnoreConsumed()) {
|
if (change.changedToUpIgnoreConsumed()) {
|
||||||
|
resetOnFinally = false
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,6 +484,13 @@ fun SwipeBackContainer(
|
|||||||
)
|
)
|
||||||
change.consume()
|
change.consume()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Сбрасываем только при отмене/прерывании жеста.
|
||||||
|
// При обычном UP сброс делаем позже, чтобы не было рывка.
|
||||||
|
if (resetOnFinally && isDragging) {
|
||||||
|
forceResetSwipeState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle drag end
|
// Handle drag end
|
||||||
|
|||||||
@@ -92,16 +92,8 @@ fun MyQrCodeScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var selectedThemeIndex by remember { mutableIntStateOf(if (isDarkTheme) 0 else 3) }
|
var selectedThemeIndex by remember { mutableIntStateOf(if (isDarkTheme) 0 else 3) }
|
||||||
|
// Local dark/light state — independent from the global app theme
|
||||||
// Auto-switch to matching theme group when app theme changes
|
var localIsDark by remember { mutableStateOf(isDarkTheme) }
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val theme = qrThemes[selectedThemeIndex]
|
val theme = qrThemes[selectedThemeIndex]
|
||||||
|
|
||||||
@@ -272,7 +264,7 @@ fun MyQrCodeScreen(
|
|||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
|
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
|
shadowElevation = 16.dp
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -299,16 +291,17 @@ fun MyQrCodeScreen(
|
|||||||
) {
|
) {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(TablerIcons.X, contentDescription = "Close",
|
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))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
Text("QR Code", fontSize = 17.sp, fontWeight = FontWeight.SemiBold,
|
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))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
var themeButtonPos by remember { mutableStateOf(Offset.Zero) }
|
var themeButtonPos by remember { mutableStateOf(Offset.Zero) }
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
// Snapshot → toggle theme → circular reveal
|
// Snapshot → toggle LOCAL theme → circular reveal
|
||||||
|
// Does NOT toggle the global app theme
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
if (!revealActive && rootSize.width > 0 && now - lastRevealTime >= revealCooldownMs) {
|
if (!revealActive && rootSize.width > 0 && now - lastRevealTime >= revealCooldownMs) {
|
||||||
lastRevealTime = now
|
lastRevealTime = now
|
||||||
@@ -319,11 +312,10 @@ fun MyQrCodeScreen(
|
|||||||
revealActive = true
|
revealActive = true
|
||||||
revealCenter = themeButtonPos
|
revealCenter = themeButtonPos
|
||||||
revealSnapshot = snapshot.asImageBitmap()
|
revealSnapshot = snapshot.asImageBitmap()
|
||||||
// Switch to matching wallpaper in new theme
|
val posInGroup = if (localIsDark) selectedThemeIndex else selectedThemeIndex - 3
|
||||||
val posInGroup = if (isDarkTheme) selectedThemeIndex else selectedThemeIndex - 3
|
val newIndex = if (localIsDark) (posInGroup + 3).coerceIn(3, 5) else posInGroup.coerceIn(0, 2)
|
||||||
val newIndex = if (isDarkTheme) (posInGroup + 3).coerceIn(3, 5) else posInGroup.coerceIn(0, 2)
|
|
||||||
selectedThemeIndex = newIndex
|
selectedThemeIndex = newIndex
|
||||||
onToggleTheme()
|
localIsDark = !localIsDark
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
revealRadius.snapTo(0f)
|
revealRadius.snapTo(0f)
|
||||||
@@ -337,11 +329,8 @@ fun MyQrCodeScreen(
|
|||||||
revealActive = false
|
revealActive = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// drawToBitmap failed — skip
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// else: cooldown active — ignore tap
|
|
||||||
},
|
},
|
||||||
modifier = Modifier.onGloballyPositioned { coords ->
|
modifier = Modifier.onGloballyPositioned { coords ->
|
||||||
val pos = coords.positionInRoot()
|
val pos = coords.positionInRoot()
|
||||||
@@ -350,9 +339,9 @@ fun MyQrCodeScreen(
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (isDarkTheme) TablerIcons.Sun else TablerIcons.MoonStars,
|
imageVector = if (localIsDark) TablerIcons.Sun else TablerIcons.MoonStars,
|
||||||
contentDescription = "Toggle theme",
|
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))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
// Wallpaper selector — show current theme's wallpapers
|
// Wallpaper selector — show current theme's wallpapers
|
||||||
val currentThemes = qrThemes.filter { it.isDark == isDarkTheme }
|
val currentThemes = qrThemes.filter { it.isDark == localIsDark }
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
@@ -394,7 +383,7 @@ fun MyQrCodeScreen(
|
|||||||
modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||||
}
|
}
|
||||||
Icon(TablerIcons.Scan, contentDescription = null,
|
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))
|
modifier = Modifier.size(22.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ data class AppIconOption(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private val iconOptions = listOf(
|
private val iconOptions = listOf(
|
||||||
AppIconOption("default", "Rosetta", "Original icon", ".MainActivityDefault", R.drawable.ic_launcher_foreground, Color(0xFF1B1B1B)),
|
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(0xFF795548)),
|
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(0xFF42A5F5)),
|
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(0xFFFFC107))
|
AppIconOption("notes", "Notes", "Disguise as notes app", ".MainActivityNotes", R.drawable.ic_notes_foreground, Color.White)
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -173,9 +173,8 @@ fun AppIconScreen(
|
|||||||
.background(option.previewBg),
|
.background(option.previewBg),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
// Default icon has 15% inset built-in — show full size
|
val imgSize = if (option.id == "default") 52.dp else 44.dp
|
||||||
val iconSize = if (option.id == "default") 52.dp else 36.dp
|
val imgScale = if (option.id == "default")
|
||||||
val scaleType = if (option.id == "default")
|
|
||||||
android.widget.ImageView.ScaleType.CENTER_CROP
|
android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
else
|
else
|
||||||
android.widget.ImageView.ScaleType.FIT_CENTER
|
android.widget.ImageView.ScaleType.FIT_CENTER
|
||||||
@@ -183,10 +182,10 @@ fun AppIconScreen(
|
|||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
android.widget.ImageView(ctx).apply {
|
android.widget.ImageView(ctx).apply {
|
||||||
setImageResource(option.iconRes)
|
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.animation.core.*
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
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.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
@@ -64,7 +66,11 @@ fun SplashScreen(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(backgroundColor),
|
.background(backgroundColor)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null
|
||||||
|
) { },
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
// Glow effect behind logo
|
// 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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<solid android:color="#795548"/>
|
<solid android:color="#FFFFFF"/>
|
||||||
</shape>
|
</shape>
|
||||||
|
|||||||
@@ -1,38 +1,7 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
android:width="108dp"
|
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:height="108dp"
|
android:inset="20%">
|
||||||
android:viewportWidth="108"
|
<bitmap
|
||||||
android:viewportHeight="108">
|
android:src="@drawable/ic_calc_downloaded"
|
||||||
<!-- Calculator — Google Calculator style: simple, bold, white on color -->
|
android:gravity="fill"/>
|
||||||
<group android:translateX="30" android:translateY="24">
|
</inset>
|
||||||
<!-- = 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>
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<solid android:color="#FFC107"/>
|
<solid android:color="#FFFFFF"/>
|
||||||
</shape>
|
</shape>
|
||||||
|
|||||||
@@ -1,52 +1,7 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
android:width="108dp"
|
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:height="108dp"
|
android:inset="20%">
|
||||||
android:viewportWidth="108"
|
<bitmap
|
||||||
android:viewportHeight="108">
|
android:src="@drawable/ic_notes_downloaded"
|
||||||
<!-- Notes — Google Keep style: white card with colored pin/accent -->
|
android:gravity="fill"/>
|
||||||
<group android:translateX="26" android:translateY="20">
|
</inset>
|
||||||
<!-- 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>
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<solid android:color="#42A5F5"/>
|
<solid android:color="#FFFFFF"/>
|
||||||
</shape>
|
</shape>
|
||||||
|
|||||||
@@ -1,32 +1,7 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
android:width="108dp"
|
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:height="108dp"
|
android:inset="20%">
|
||||||
android:viewportWidth="108"
|
<bitmap
|
||||||
android:viewportHeight="108">
|
android:src="@drawable/ic_weather_downloaded"
|
||||||
<!-- Weather — sun with cloud, Material You style, centered in safe zone -->
|
android:gravity="fill"/>
|
||||||
<group android:translateX="20" android:translateY="20">
|
</inset>
|
||||||
<!-- 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>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user