Добавлен экран Calls в сайдбар и улучшено управление историей звонков
This commit is contained in:
@@ -1015,6 +1015,9 @@ fun MainScreen(
|
||||
onUserSelect = { selectedChatUser ->
|
||||
pushScreen(Screen.ChatDetail(selectedChatUser))
|
||||
},
|
||||
onStartCall = { user ->
|
||||
startCallWithPermission(user)
|
||||
},
|
||||
backgroundBlurColorId = backgroundBlurColorId,
|
||||
pinnedChats = pinnedChats,
|
||||
onTogglePin = { opponentKey ->
|
||||
|
||||
@@ -174,6 +174,16 @@ interface GroupDao {
|
||||
suspend fun deleteAllByAccount(account: String): Int
|
||||
}
|
||||
|
||||
/** Строка истории звонков (messages + данные собеседника из dialogs) */
|
||||
data class CallHistoryRow(
|
||||
@Embedded val message: MessageEntity,
|
||||
@ColumnInfo(name = "peer_key") val peerKey: String,
|
||||
@ColumnInfo(name = "peer_title") val peerTitle: String?,
|
||||
@ColumnInfo(name = "peer_username") val peerUsername: String?,
|
||||
@ColumnInfo(name = "peer_verified") val peerVerified: Int?,
|
||||
@ColumnInfo(name = "peer_online") val peerOnline: Int?
|
||||
)
|
||||
|
||||
/** DAO для работы с сообщениями */
|
||||
@Dao
|
||||
interface MessageDao {
|
||||
@@ -559,6 +569,65 @@ interface MessageDao {
|
||||
)
|
||||
suspend fun getMessagesWithFiles(account: String, limit: Int, offset: Int): List<MessageEntity>
|
||||
|
||||
/**
|
||||
* 📞 История звонков на основе CALL attachments (type: 4)
|
||||
* LEFT JOIN на dialogs нужен для имени/username/verified без дополнительных запросов.
|
||||
*/
|
||||
@Query(
|
||||
"""
|
||||
SELECT
|
||||
m.*,
|
||||
CASE
|
||||
WHEN m.from_me = 1 THEN m.to_public_key
|
||||
ELSE m.from_public_key
|
||||
END AS peer_key,
|
||||
d.opponent_title AS peer_title,
|
||||
d.opponent_username AS peer_username,
|
||||
d.verified AS peer_verified,
|
||||
d.is_online AS peer_online
|
||||
FROM messages m
|
||||
LEFT JOIN dialogs d
|
||||
ON d.account = m.account
|
||||
AND d.opponent_key = CASE
|
||||
WHEN m.from_me = 1 THEN m.to_public_key
|
||||
ELSE m.from_public_key
|
||||
END
|
||||
WHERE m.account = :account
|
||||
AND m.attachments != '[]'
|
||||
AND (m.attachments LIKE '%"type":4%' OR m.attachments LIKE '%"type": 4%')
|
||||
ORDER BY m.timestamp DESC, m.message_id DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
fun getCallHistoryFlow(account: String, limit: Int = 300): Flow<List<CallHistoryRow>>
|
||||
|
||||
/** Пиры, у которых есть call attachments (нужно для пересчета dialogs после удаления). */
|
||||
@Query(
|
||||
"""
|
||||
SELECT DISTINCT
|
||||
CASE
|
||||
WHEN from_me = 1 THEN to_public_key
|
||||
ELSE from_public_key
|
||||
END AS peer_key
|
||||
FROM messages
|
||||
WHERE account = :account
|
||||
AND attachments != '[]'
|
||||
AND (attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%')
|
||||
"""
|
||||
)
|
||||
suspend fun getCallHistoryPeers(account: String): List<String>
|
||||
|
||||
/** Удалить все call events из messages для аккаунта. */
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM messages
|
||||
WHERE account = :account
|
||||
AND attachments != '[]'
|
||||
AND (attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%')
|
||||
"""
|
||||
)
|
||||
suspend fun deleteAllCallMessages(account: String): Int
|
||||
|
||||
/** Все сообщения аккаунта (для поиска по тексту), отсортированные по дате */
|
||||
@Query(
|
||||
"""
|
||||
|
||||
@@ -63,10 +63,12 @@ import com.rosetta.messenger.data.EncryptedAccount
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.data.RecentSearchesManager
|
||||
import com.rosetta.messenger.data.resolveAccountDisplayName
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.DeviceEntry
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.ProtocolState
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.chats.calls.CallsHistoryScreen
|
||||
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
|
||||
import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||
@@ -260,6 +262,7 @@ fun ChatsListScreen(
|
||||
onRequestsClick: () -> Unit = {},
|
||||
onNewChat: () -> Unit,
|
||||
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
|
||||
onStartCall: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
|
||||
backgroundBlurColorId: String = "avatar",
|
||||
pinnedChats: Set<String> = emptySet(),
|
||||
onTogglePin: (String) -> Unit = {},
|
||||
@@ -477,6 +480,7 @@ fun ChatsListScreen(
|
||||
}
|
||||
.sortedByDescending { it.progress }
|
||||
}
|
||||
val database = remember(context) { RosettaDatabase.getDatabase(context) }
|
||||
val activeFileDownloads = remember(accountFileDownloads) {
|
||||
accountFileDownloads.filter {
|
||||
it.status == com.rosetta.messenger.network.FileDownloadStatus.QUEUED ||
|
||||
@@ -520,14 +524,19 @@ fun ChatsListScreen(
|
||||
|
||||
// 📬 Requests screen state
|
||||
var showRequestsScreen by remember { mutableStateOf(false) }
|
||||
var showCallsScreen by remember { mutableStateOf(false) }
|
||||
var showCallsMenu by remember { mutableStateOf(false) }
|
||||
var showDownloadsScreen by remember { mutableStateOf(false) }
|
||||
var isInlineRequestsTransitionLocked by remember { mutableStateOf(false) }
|
||||
var isInlineCallsTransitionLocked by remember { mutableStateOf(false) }
|
||||
var isRequestsRouteTapLocked by remember { mutableStateOf(false) }
|
||||
val inlineRequestsTransitionLockMs = 340L
|
||||
val inlineCallsTransitionLockMs = 340L
|
||||
val requestsRouteTapLockMs = 420L
|
||||
|
||||
fun setInlineRequestsVisible(visible: Boolean) {
|
||||
if (showRequestsScreen == visible || isInlineRequestsTransitionLocked) return
|
||||
if (visible) showCallsScreen = false
|
||||
isInlineRequestsTransitionLocked = true
|
||||
showRequestsScreen = visible
|
||||
scope.launch {
|
||||
@@ -536,6 +545,52 @@ fun ChatsListScreen(
|
||||
}
|
||||
}
|
||||
|
||||
fun setInlineCallsVisible(visible: Boolean) {
|
||||
if (showCallsScreen == visible || isInlineCallsTransitionLocked) return
|
||||
if (visible) showRequestsScreen = false
|
||||
isInlineCallsTransitionLocked = true
|
||||
showCallsScreen = visible
|
||||
if (!visible) showCallsMenu = false
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(inlineCallsTransitionLockMs)
|
||||
isInlineCallsTransitionLocked = false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAllCallsHistory(): Int {
|
||||
if (accountPublicKey.isBlank()) return 0
|
||||
val messageDao = database.messageDao()
|
||||
val dialogDao = database.dialogDao()
|
||||
val peers = messageDao.getCallHistoryPeers(accountPublicKey).map { it.trim() }
|
||||
.filter { it.isNotBlank() }.distinct()
|
||||
|
||||
val deletedCount = messageDao.deleteAllCallMessages(accountPublicKey)
|
||||
if (deletedCount <= 0) return 0
|
||||
|
||||
peers.forEach { peerKey ->
|
||||
val dialogKey =
|
||||
if (accountPublicKey == peerKey) {
|
||||
accountPublicKey
|
||||
} else if (accountPublicKey < peerKey) {
|
||||
"$accountPublicKey:$peerKey"
|
||||
} else {
|
||||
"$peerKey:$accountPublicKey"
|
||||
}
|
||||
|
||||
val remaining = messageDao.getMessageCount(accountPublicKey, dialogKey)
|
||||
if (remaining > 0) {
|
||||
if (peerKey == accountPublicKey) {
|
||||
dialogDao.updateSavedMessagesDialogFromMessages(accountPublicKey)
|
||||
} else {
|
||||
dialogDao.updateDialogFromMessages(accountPublicKey, peerKey)
|
||||
}
|
||||
} else {
|
||||
dialogDao.deleteDialog(accountPublicKey, peerKey)
|
||||
}
|
||||
}
|
||||
return deletedCount
|
||||
}
|
||||
|
||||
fun openRequestsRouteSafely() {
|
||||
if (isRequestsRouteTapLocked) return
|
||||
isRequestsRouteTapLocked = true
|
||||
@@ -548,6 +603,7 @@ fun ChatsListScreen(
|
||||
|
||||
LaunchedEffect(currentAccountKey) {
|
||||
showDownloadsScreen = false
|
||||
showCallsScreen = false
|
||||
}
|
||||
|
||||
// 📂 Accounts section expanded state (arrow toggle)
|
||||
@@ -571,6 +627,7 @@ fun ChatsListScreen(
|
||||
var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||
var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||
var accountToDelete by remember { mutableStateOf<EncryptedAccount?>(null) }
|
||||
var showDeleteCallsDialog by remember { mutableStateOf(false) }
|
||||
var deviceResolveRequest by
|
||||
remember {
|
||||
mutableStateOf<Pair<DeviceEntry, DeviceResolveAction>?>(null)
|
||||
@@ -587,9 +644,11 @@ fun ChatsListScreen(
|
||||
|
||||
// Back: drawer → закрыть, selection → сбросить
|
||||
// Когда ничего не открыто — НЕ перехватываем, система сама закроет приложение корректно
|
||||
BackHandler(enabled = showDownloadsScreen || isSelectionMode || drawerState.isOpen) {
|
||||
BackHandler(enabled = showDownloadsScreen || showCallsScreen || isSelectionMode || drawerState.isOpen) {
|
||||
if (showDownloadsScreen) {
|
||||
showDownloadsScreen = false
|
||||
} else if (showCallsScreen) {
|
||||
setInlineCallsVisible(false)
|
||||
} else if (isSelectionMode) {
|
||||
selectedChatKeys = emptySet()
|
||||
} else if (drawerState.isOpen) {
|
||||
@@ -766,7 +825,7 @@ fun ChatsListScreen(
|
||||
) {
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
gesturesEnabled = !showRequestsScreen && !showDownloadsScreen,
|
||||
gesturesEnabled = !showRequestsScreen && !showDownloadsScreen && !showCallsScreen,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet(
|
||||
drawerContainerColor = Color.Transparent,
|
||||
@@ -1194,6 +1253,23 @@ fun ChatsListScreen(
|
||||
}
|
||||
)
|
||||
|
||||
// 📞 Calls
|
||||
DrawerMenuItemEnhanced(
|
||||
icon = TablerIcons.Phone,
|
||||
text = "Calls",
|
||||
iconColor = menuIconColor,
|
||||
textColor = menuTextColor,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
drawerState.close()
|
||||
kotlinx.coroutines
|
||||
.delay(100)
|
||||
setInlineCallsVisible(true)
|
||||
onCallsClick()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 👥 New Group
|
||||
DrawerMenuItemEnhanced(
|
||||
icon = TablerIcons.Users,
|
||||
@@ -1413,6 +1489,7 @@ fun ChatsListScreen(
|
||||
key(
|
||||
isDarkTheme,
|
||||
showRequestsScreen,
|
||||
showCallsScreen,
|
||||
showDownloadsScreen,
|
||||
isSelectionMode
|
||||
) {
|
||||
@@ -1553,11 +1630,15 @@ fun ChatsListScreen(
|
||||
// ═══ NORMAL HEADER ═══
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
if (showRequestsScreen || showDownloadsScreen) {
|
||||
if (showRequestsScreen || showDownloadsScreen || showCallsScreen) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (showDownloadsScreen) {
|
||||
showDownloadsScreen = false
|
||||
} else if (showCallsScreen) {
|
||||
setInlineCallsVisible(
|
||||
false
|
||||
)
|
||||
} else {
|
||||
setInlineRequestsVisible(
|
||||
false
|
||||
@@ -1650,6 +1731,13 @@ fun ChatsListScreen(
|
||||
fontSize = 20.sp,
|
||||
color = Color.White
|
||||
)
|
||||
} else if (showCallsScreen) {
|
||||
Text(
|
||||
"Calls",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
color = Color.White
|
||||
)
|
||||
} else if (showRequestsScreen) {
|
||||
Text(
|
||||
"Requests",
|
||||
@@ -1689,7 +1777,50 @@ fun ChatsListScreen(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (!showRequestsScreen && !showDownloadsScreen) {
|
||||
if (showCallsScreen) {
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = {
|
||||
showCallsMenu = true
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
TablerIcons.DotsVertical,
|
||||
contentDescription = "Calls menu",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showCallsMenu,
|
||||
onDismissRequest = {
|
||||
showCallsMenu = false
|
||||
},
|
||||
modifier = Modifier.background(
|
||||
if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
|
||||
)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
"Delete all calls",
|
||||
color = if (isDarkTheme) Color.White else Color.Black
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
showCallsMenu = false
|
||||
showDeleteCallsDialog = true
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = TablerIcons.Trash,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFE55A5A)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (!showRequestsScreen && !showDownloadsScreen) {
|
||||
// 📥 Animated download indicator (Telegram-style)
|
||||
Box(
|
||||
modifier =
|
||||
@@ -1897,6 +2028,97 @@ fun ChatsListScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
// 🎬 Animated content transition between main list and
|
||||
// calls
|
||||
AnimatedContent(
|
||||
targetState = showCallsScreen,
|
||||
transitionSpec = {
|
||||
if (targetState) {
|
||||
slideInHorizontally(
|
||||
animationSpec = tween(280, easing = FastOutSlowInEasing)
|
||||
) { fullWidth -> fullWidth } + fadeIn(
|
||||
animationSpec = tween(200)
|
||||
) togetherWith
|
||||
slideOutHorizontally(
|
||||
animationSpec = tween(280, easing = FastOutSlowInEasing)
|
||||
) { fullWidth -> -fullWidth / 4 } + fadeOut(
|
||||
animationSpec = tween(150)
|
||||
)
|
||||
} else {
|
||||
slideInHorizontally(
|
||||
animationSpec = tween(280, easing = FastOutSlowInEasing)
|
||||
) { fullWidth -> -fullWidth / 4 } + fadeIn(
|
||||
animationSpec = tween(200)
|
||||
) togetherWith
|
||||
slideOutHorizontally(
|
||||
animationSpec = tween(280, easing = FastOutSlowInEasing)
|
||||
) { fullWidth -> fullWidth } + fadeOut(
|
||||
animationSpec = tween(150)
|
||||
)
|
||||
}
|
||||
},
|
||||
label = "CallsTransition"
|
||||
) { isCallsScreen ->
|
||||
if (isCallsScreen) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
val velocityTracker = androidx.compose.ui.input.pointer.util.VelocityTracker()
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
velocityTracker.resetTracking()
|
||||
velocityTracker.addPosition(down.uptimeMillis, down.position)
|
||||
var totalDragX = 0f
|
||||
var totalDragY = 0f
|
||||
var claimed = false
|
||||
val touchSlop = viewConfiguration.touchSlop * 0.6f
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||
if (change.changedToUpIgnoreConsumed()) break
|
||||
|
||||
val delta = change.positionChange()
|
||||
totalDragX += delta.x
|
||||
totalDragY += delta.y
|
||||
velocityTracker.addPosition(change.uptimeMillis, change.position)
|
||||
|
||||
if (!claimed) {
|
||||
val distance = kotlin.math.sqrt(totalDragX * totalDragX + totalDragY * totalDragY)
|
||||
if (distance < touchSlop) continue
|
||||
if (totalDragX > 0 && kotlin.math.abs(totalDragX) > kotlin.math.abs(totalDragY) * 1.2f) {
|
||||
claimed = true
|
||||
change.consume()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
|
||||
if (claimed) {
|
||||
val velocityX = velocityTracker.calculateVelocity().x
|
||||
val screenWidth = size.width.toFloat()
|
||||
if (totalDragX > screenWidth * 0.08f || velocityX > 200f) {
|
||||
setInlineCallsVisible(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
CallsHistoryScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
accountPublicKey = accountPublicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
onOpenChat = onUserSelect,
|
||||
onStartCall = onStartCall,
|
||||
onStartNewCall = onSearchClick,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// 🎬 Animated content transition between main list and
|
||||
// requests
|
||||
@@ -2592,7 +2814,9 @@ fun ChatsListScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
} // Close AnimatedContent
|
||||
} // Close Requests AnimatedContent
|
||||
} // Close calls/main switch
|
||||
} // Close Calls AnimatedContent
|
||||
} // Close downloads/main content switch
|
||||
} // Close Downloads AnimatedContent
|
||||
|
||||
@@ -2604,6 +2828,56 @@ fun ChatsListScreen(
|
||||
|
||||
// 🔥 Confirmation Dialogs
|
||||
|
||||
if (showDeleteCallsDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteCallsDialog = false },
|
||||
containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
|
||||
title = {
|
||||
Text(
|
||||
"Delete all calls",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
"This will remove all call records from history. Chats and contacts will stay unchanged.",
|
||||
color = secondaryTextColor
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDeleteCallsDialog = false
|
||||
scope.launch {
|
||||
val deleted = clearAllCallsHistory()
|
||||
if (deleted > 0) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Deleted $deleted call records",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Call history is already empty",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Delete", color = Color(0xFFE55A5A))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteCallsDialog = false }) {
|
||||
Text("Cancel", color = PrimaryBlue)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Delete Dialog Confirmation
|
||||
if (dialogsToDelete.isNotEmpty()) {
|
||||
val count = dialogsToDelete.size
|
||||
|
||||
@@ -96,29 +96,19 @@ fun CallOverlay(
|
||||
Brush.verticalGradient(listOf(GradientTop, GradientMid, GradientBottom))
|
||||
)
|
||||
) {
|
||||
// ── Top bar: "Encrypted" left + QR icon right ──
|
||||
if (state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) {
|
||||
Row(
|
||||
// ── Top-right QR icon ──
|
||||
if ((state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) && state.keyCast.isNotBlank()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.TopCenter)
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
Text(
|
||||
text = "\uD83D\uDD12 Encrypted",
|
||||
color = Color.White.copy(alpha = 0.4f),
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
|
||||
// QR grid icon — tap to show popover
|
||||
if (state.keyCast.isNotBlank()) {
|
||||
EncryptionKeyButton(keyHex = state.keyCast)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Center content: rings + avatar + name + status ──
|
||||
Column(
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
package com.rosetta.messenger.ui.chats.calls
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.CallMade
|
||||
import androidx.compose.material.icons.filled.CallReceived
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.rosetta.messenger.database.CallHistoryRow
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import compose.icons.TablerIcons
|
||||
import compose.icons.tablericons.Phone
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import org.json.JSONArray
|
||||
|
||||
private data class CallHistoryItem(
|
||||
val messageId: String,
|
||||
val peerKey: String,
|
||||
val peerTitle: String,
|
||||
val peerUsername: String,
|
||||
val peerVerified: Int,
|
||||
val peerOnline: Int,
|
||||
val timestamp: Long,
|
||||
val isOutgoing: Boolean,
|
||||
val durationSec: Int,
|
||||
val isMissed: Boolean
|
||||
) {
|
||||
fun toSearchUser(): SearchUser =
|
||||
SearchUser(
|
||||
publicKey = peerKey,
|
||||
title = peerTitle,
|
||||
username = peerUsername,
|
||||
verified = peerVerified,
|
||||
online = peerOnline
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CallsHistoryScreen(
|
||||
isDarkTheme: Boolean,
|
||||
accountPublicKey: String,
|
||||
avatarRepository: AvatarRepository?,
|
||||
onOpenChat: (SearchUser) -> Unit,
|
||||
onStartCall: (SearchUser) -> Unit,
|
||||
onStartNewCall: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val messageDao = remember(context) { RosettaDatabase.getDatabase(context).messageDao() }
|
||||
|
||||
val rows by produceState(initialValue = emptyList<CallHistoryRow>(), accountPublicKey) {
|
||||
if (accountPublicKey.isBlank()) {
|
||||
value = emptyList()
|
||||
return@produceState
|
||||
}
|
||||
messageDao.getCallHistoryFlow(accountPublicKey).collect { value = it }
|
||||
}
|
||||
|
||||
val items = remember(rows) { rows.map { it.toCallHistoryItem() } }
|
||||
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||
val textColor = if (isDarkTheme) Color.White else Color(0xFF111111)
|
||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF9D9DA3) else Color(0xFF7A7A80)
|
||||
val dividerColor = if (isDarkTheme) Color(0xFF2D2D2F) else Color(0xFFE7E7EA)
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize().background(backgroundColor),
|
||||
contentPadding = PaddingValues(bottom = 16.dp)
|
||||
) {
|
||||
item(key = "start_new_call") {
|
||||
StartNewCallRow(
|
||||
isDarkTheme = isDarkTheme,
|
||||
onClick = onStartNewCall
|
||||
)
|
||||
Divider(color = dividerColor, thickness = 0.5.dp)
|
||||
Text(
|
||||
text = "You can add up to 200 participants to a call.",
|
||||
color = secondaryTextColor,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
)
|
||||
Divider(color = dividerColor, thickness = 0.5.dp)
|
||||
}
|
||||
|
||||
if (items.isEmpty()) {
|
||||
item(key = "empty_calls") {
|
||||
EmptyCallsState(
|
||||
isDarkTheme = isDarkTheme,
|
||||
title = "No calls yet",
|
||||
subtitle = "Your call history will appear here",
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 64.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(items, key = { it.messageId }) { item ->
|
||||
CallHistoryRowItem(
|
||||
item = item,
|
||||
isDarkTheme = isDarkTheme,
|
||||
avatarRepository = avatarRepository,
|
||||
textColor = textColor,
|
||||
secondaryTextColor = secondaryTextColor,
|
||||
onOpenChat = onOpenChat,
|
||||
onStartCall = onStartCall
|
||||
)
|
||||
Divider(color = dividerColor, thickness = 0.5.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StartNewCallRow(
|
||||
isDarkTheme: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val rowColor = if (isDarkTheme) Color(0xFF1B2B3A) else Color(0xFFEAF4FF)
|
||||
val textColor = if (isDarkTheme) Color(0xFF74B8FF) else Color(0xFF1A73E8)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().background(rowColor).clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.Phone,
|
||||
contentDescription = null,
|
||||
tint = textColor,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Start New Call",
|
||||
color = textColor,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallHistoryRowItem(
|
||||
item: CallHistoryItem,
|
||||
isDarkTheme: Boolean,
|
||||
avatarRepository: AvatarRepository?,
|
||||
textColor: Color,
|
||||
secondaryTextColor: Color,
|
||||
onOpenChat: (SearchUser) -> Unit,
|
||||
onStartCall: (SearchUser) -> Unit
|
||||
) {
|
||||
val subtitleColor =
|
||||
when {
|
||||
item.isMissed -> Color(0xFFE55A5A)
|
||||
isDarkTheme -> Color(0xFF56D97A)
|
||||
else -> Color(0xFF1EA75E)
|
||||
}
|
||||
val directionIconColor =
|
||||
if (item.durationSec == 0) Color(0xFFE55A5A) else subtitleColor
|
||||
val directionIcon =
|
||||
when {
|
||||
item.durationSec == 0 -> Icons.Default.Close
|
||||
item.isOutgoing -> Icons.Default.CallMade
|
||||
else -> Icons.Default.CallReceived
|
||||
}
|
||||
val subtitleText =
|
||||
when {
|
||||
item.durationSec > 0 -> "${item.directionLabel()} ${formatCallTimestamp(item.timestamp)}"
|
||||
else -> item.directionLabel() + " " + formatCallTimestamp(item.timestamp)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.clickable { onOpenChat(item.toSearchUser()) }
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AvatarImage(
|
||||
publicKey = item.peerKey,
|
||||
avatarRepository = avatarRepository,
|
||||
size = 52.dp,
|
||||
isDarkTheme = isDarkTheme,
|
||||
displayName = item.peerTitle
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = item.peerTitle,
|
||||
color = textColor,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(3.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = directionIcon,
|
||||
contentDescription = null,
|
||||
tint = directionIconColor,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = subtitleText,
|
||||
color = if (item.isMissed) Color(0xFFE55A5A) else secondaryTextColor,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = { onStartCall(item.toSearchUser()) }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Call,
|
||||
contentDescription = "Call",
|
||||
tint = PrimaryBlue
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyCallsState(
|
||||
isDarkTheme: Boolean,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val iconTint = if (isDarkTheme) Color(0xFF5B5C63) else Color(0xFFAFB0B8)
|
||||
val titleColor = if (isDarkTheme) Color(0xFFE1E1E6) else Color(0xFF1F1F23)
|
||||
val subtitleColor = if (isDarkTheme) Color(0xFF9D9DA3) else Color(0xFF7A7A80)
|
||||
|
||||
Column(
|
||||
modifier = modifier.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(72.dp).background(iconTint.copy(alpha = 0.2f), CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Call,
|
||||
contentDescription = null,
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(34.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Text(
|
||||
text = title,
|
||||
color = titleColor,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = subtitleColor,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CallHistoryRow.toCallHistoryItem(): CallHistoryItem {
|
||||
val displayName = resolveDisplayName(peerTitle.orEmpty(), peerUsername.orEmpty(), peerKey)
|
||||
val username = peerUsername.orEmpty().trim().trimStart('@')
|
||||
val durationSec = parseCallDurationFromAttachments(message.attachments)
|
||||
val isOutgoing = message.fromMe == 1
|
||||
val isMissed = !isOutgoing && durationSec == 0
|
||||
|
||||
return CallHistoryItem(
|
||||
messageId = message.messageId,
|
||||
peerKey = peerKey,
|
||||
peerTitle = displayName,
|
||||
peerUsername = username,
|
||||
peerVerified = peerVerified ?: 0,
|
||||
peerOnline = peerOnline ?: 0,
|
||||
timestamp = message.timestamp,
|
||||
isOutgoing = isOutgoing,
|
||||
durationSec = durationSec,
|
||||
isMissed = isMissed
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveDisplayName(title: String, username: String, publicKey: String): String {
|
||||
val normalizedTitle = title.trim()
|
||||
if (normalizedTitle.isNotEmpty() &&
|
||||
normalizedTitle != publicKey &&
|
||||
normalizedTitle != publicKey.take(7) &&
|
||||
normalizedTitle != publicKey.take(8)
|
||||
) {
|
||||
return normalizedTitle
|
||||
}
|
||||
|
||||
val normalizedUsername = username.trim().trimStart('@')
|
||||
if (normalizedUsername.isNotEmpty()) return normalizedUsername
|
||||
|
||||
return publicKey.take(8)
|
||||
}
|
||||
|
||||
private fun parseCallDurationFromAttachments(attachmentsJson: String): Int {
|
||||
if (attachmentsJson.isBlank() || attachmentsJson == "[]") return 0
|
||||
return runCatching {
|
||||
val attachments = JSONArray(attachmentsJson)
|
||||
for (i in 0 until attachments.length()) {
|
||||
val attachment = attachments.optJSONObject(i) ?: continue
|
||||
if (attachment.optInt("type", -1) != 4) continue
|
||||
return parseCallDurationSeconds(attachment.optString("preview", ""))
|
||||
}
|
||||
0
|
||||
}.getOrDefault(0)
|
||||
}
|
||||
|
||||
private fun parseCallDurationSeconds(preview: String): Int {
|
||||
if (preview.isBlank()) return 0
|
||||
|
||||
preview.substringAfterLast("::").trim().toIntOrNull()?.let {
|
||||
return it.coerceAtLeast(0)
|
||||
}
|
||||
|
||||
val durationRegex =
|
||||
Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*(\\d+)", RegexOption.IGNORE_CASE)
|
||||
durationRegex.find(preview)?.groupValues?.getOrNull(1)?.toIntOrNull()?.let {
|
||||
return it.coerceAtLeast(0)
|
||||
}
|
||||
|
||||
return preview.trim().toIntOrNull()?.coerceAtLeast(0) ?: 0
|
||||
}
|
||||
|
||||
private fun CallHistoryItem.directionLabel(): String {
|
||||
return when {
|
||||
durationSec == 0 && isOutgoing -> "Rejected call"
|
||||
durationSec == 0 && !isOutgoing -> "Missed call"
|
||||
isOutgoing -> "Outgoing call"
|
||||
else -> "Incoming call"
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatCallTimestamp(timestamp: Long): String {
|
||||
if (timestamp <= 0L) return ""
|
||||
val now = Calendar.getInstance()
|
||||
val callTime = Calendar.getInstance().apply { timeInMillis = timestamp }
|
||||
|
||||
val sameYear = now.get(Calendar.YEAR) == callTime.get(Calendar.YEAR)
|
||||
val sameDay =
|
||||
sameYear && now.get(Calendar.DAY_OF_YEAR) == callTime.get(Calendar.DAY_OF_YEAR)
|
||||
|
||||
val yesterday = now.clone() as Calendar
|
||||
yesterday.add(Calendar.DAY_OF_YEAR, -1)
|
||||
val isYesterday =
|
||||
sameYear && yesterday.get(Calendar.DAY_OF_YEAR) == callTime.get(Calendar.DAY_OF_YEAR)
|
||||
|
||||
return when {
|
||||
sameDay -> SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp))
|
||||
isYesterday -> "Yesterday"
|
||||
else -> SimpleDateFormat("dd MMM", Locale.getDefault()).format(Date(timestamp))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user