- change fadeIn fadeOut animation
- implement swipe back
- add last seen
- add avatar in chat
This commit is contained in:
YaAndreyIgorevich 2026-03-08 02:44:18 +07:00
parent 23a7e98b4d
commit 8a6035be49
11 changed files with 886 additions and 314 deletions

View File

@ -14,6 +14,7 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:enableOnBackInvokedCallback="true"
android:theme="@style/Theme.Yobble">
<activity
android:name=".MainActivity"

View File

@ -17,7 +17,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material3.*
import androidx.compose.runtime.*
@ -35,6 +34,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.yobble.messenger.data.remote.dto.MessageItemDto
import org.yobble.messenger.presentation.common.UserAvatar
import org.yobble.messenger.util.formatLastSeen
import org.yobble.messenger.util.formatUtcToLocalTime
@OptIn(ExperimentalMaterial3Api::class)
@ -108,36 +109,38 @@ fun ChatScreen(
contentWindowInsets = WindowInsets(0),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
CenterAlignedTopAppBar(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable {
uiState.otherUserId?.let { onNavigateToProfile(it) }
}
) {
Text(uiState.chatTitle, fontWeight = FontWeight.Bold)
if (uiState.isVerified) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
Icons.Default.Verified,
contentDescription = "Verified",
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(18.dp)
)
}
if (uiState.rating != null) {
Spacer(modifier = Modifier.width(8.dp))
Icon(
Icons.Default.Star,
contentDescription = "Rating",
tint = Color(0xFFFFC107),
modifier = Modifier.size(16.dp)
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = String.format("%.1f", uiState.rating),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f)
uiState.chatTitle,
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
maxLines = 1
)
if (uiState.isVerified) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
Icons.Default.Verified,
contentDescription = "Verified",
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(16.dp)
)
}
}
val lastSeenText = formatLastSeen(uiState.otherLastSeen)
if (lastSeenText.isNotEmpty()) {
Text(
text = lastSeenText,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
maxLines = 1
)
}
}
@ -147,10 +150,26 @@ fun ChatScreen(
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
actions = {
if (uiState.otherUserId != null) {
IconButton(onClick = {
uiState.otherUserId?.let { onNavigateToProfile(it) }
}) {
UserAvatar(
userId = uiState.otherUserId,
fileId = uiState.otherAvatarFileId,
displayName = uiState.chatTitle,
size = 32.dp,
fontSize = 13.sp
)
}
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
)
)
},

View File

@ -27,7 +27,6 @@ data class ChatUiState(
val chatId: String = "",
val chatTitle: String = "Chat",
val isVerified: Boolean = false,
val rating: Double? = null,
val canSendMessage: Boolean = true,
val messages: List<MessageItemDto> = emptyList(),
val messageText: String = "",
@ -36,6 +35,8 @@ data class ChatUiState(
val hasMore: Boolean = false,
val currentUserId: String = "",
val otherUserId: String? = null,
val otherAvatarFileId: String? = null,
val otherLastSeen: Int? = null,
val scrollToMessageId: String? = null
)
@ -166,7 +167,7 @@ class ChatViewModel @Inject constructor(
val otherUser = otherMessage?.senderData
val title = otherUser?.customName
?: otherUser?.fullName
?: otherUser?.login?.let { "@$it" }
?: otherUser?.login
?: _uiState.value.chatTitle
val scrollTarget = if (_uiState.value.messages.isEmpty() && !fromCache) savedMessageId else null
_uiState.update {
@ -174,8 +175,9 @@ class ChatViewModel @Inject constructor(
messages = items,
chatTitle = title,
otherUserId = otherMessage?.senderId,
otherAvatarFileId = otherUser?.avatars?.current?.fileId,
otherLastSeen = otherUser?.lastSeen,
isVerified = otherUser?.isVerified == true,
rating = otherUser?.rating?.rating,
canSendMessage = otherUser?.permissions?.youCanSendMessage != false,
hasMore = hasMore,
isLoading = if (fromCache) true else false,

View File

@ -0,0 +1,83 @@
package org.yobble.messenger.presentation.common
import androidx.activity.BackEventCompat
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import kotlin.math.sqrt
@Composable
fun SwipeBackContainer(
onBack: () -> Unit,
content: @Composable () -> Unit
) {
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val density = LocalDensity.current
var accumulated by remember { mutableFloatStateOf(0f) }
var started by remember { mutableStateOf(false) }
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val screenWidthPx = with(density) { maxWidth.toPx() }
val draggableState = rememberDraggableState { delta ->
if (started) {
accumulated = (accumulated + delta).coerceAtLeast(0f)
// Damped progress — screen moves slower than the finger
val linear = (accumulated / screenWidthPx).coerceIn(0f, 1f)
val progress = sqrt(linear) * 0.6f
try {
backDispatcher?.dispatchOnBackProgressed(
BackEventCompat(accumulated, 0f, progress.coerceIn(0f, 1f), BackEventCompat.EDGE_LEFT)
)
} catch (_: Exception) {}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.draggable(
state = draggableState,
orientation = Orientation.Horizontal,
onDragStarted = { offset ->
accumulated = 0f
started = true
try {
backDispatcher?.dispatchOnBackStarted(
BackEventCompat(offset.x, offset.y, 0f, BackEventCompat.EDGE_LEFT)
)
} catch (_: Exception) {}
},
onDragStopped = { velocity ->
if (started) {
val linear = accumulated / screenWidthPx
val progress = sqrt(linear) * 0.6f
if (progress > 0.25f || velocity > 1500f) {
backDispatcher?.onBackPressed()
} else {
try {
backDispatcher?.dispatchOnBackCancelled()
} catch (_: Exception) {}
}
}
started = false
accumulated = 0f
}
)
) {
content()
}
}
}

View File

@ -1,12 +1,9 @@
package org.yobble.messenger.presentation.contacts
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@ -20,30 +17,46 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.yobble.messenger.data.remote.dto.ContactInfoDto
import org.yobble.messenger.presentation.common.UserAvatar
import kotlin.math.roundToInt
@Composable
fun ContactsScreen(
onNavigateToChat: (chatId: String) -> Unit,
bottomPadding: androidx.compose.ui.unit.Dp,
selectedContactIds: Set<String> = emptySet(),
onToggleContactSelection: (String) -> Unit = {},
deleteContactIds: Set<String> = emptySet(),
onDeleteContactsHandled: () -> Unit = {},
renameContactId: String? = null,
onRenameContactHandled: () -> Unit = {},
viewModel: ContactsViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(deleteContactIds) {
if (deleteContactIds.isNotEmpty()) {
deleteContactIds.forEach { viewModel.removeContact(it) }
onDeleteContactsHandled()
}
}
LaunchedEffect(renameContactId) {
if (renameContactId != null) {
val contact = uiState.contacts.find { it.userId == renameContactId }
viewModel.showRenameDialog(renameContactId, contact?.customName ?: "")
onRenameContactHandled()
}
}
LaunchedEffect(Unit) {
viewModel.events.collectLatest { event ->
when (event) {
@ -76,6 +89,7 @@ fun ContactsScreen(
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = Color.Transparent,
floatingActionButton = {
FloatingActionButton(
onClick = viewModel::showAddDialog,
@ -148,10 +162,19 @@ fun ContactsScreen(
}
} else {
items(uiState.contacts, key = { it.userId }) { contact ->
SwipeableContactItem(
ContactItem(
contact = contact,
isSelected = contact.userId in selectedContactIds,
inSelectionMode = selectedContactIds.isNotEmpty(),
isCreatingChat = uiState.creatingChatForUserId == contact.userId,
onClick = { viewModel.createChatWithContact(contact.userId) },
onClick = {
if (selectedContactIds.isNotEmpty()) {
onToggleContactSelection(contact.userId)
} else {
viewModel.createChatWithContact(contact.userId)
}
},
onLongClick = { onToggleContactSelection(contact.userId) },
onRename = { viewModel.showRenameDialog(contact.userId, contact.customName ?: "") },
onRemove = { viewModel.removeContact(contact.userId) }
)
@ -179,97 +202,78 @@ fun ContactsScreen(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SwipeableContactItem(
private fun ContactItem(
contact: ContactInfoDto,
isSelected: Boolean,
inSelectionMode: Boolean,
isCreatingChat: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
onRename: () -> Unit,
onRemove: () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val offsetX = remember { Animatable(0f) }
val density = LocalDensity.current
val actionWidthPx = with(density) { 140.dp.toPx() }
val displayName = contact.customName
?: contact.fullName
?: contact.login
?: "User"
Box(
modifier = Modifier
.fillMaxWidth()
.clipToBounds()
) {
// Action buttons behind
val bgColor = if (isSelected)
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
else
Color.Transparent
Box {
Row(
modifier = Modifier
.matchParentSize(),
horizontalArrangement = Arrangement.End
) {
Box(
modifier = Modifier
.fillMaxHeight()
.width(70.dp)
.background(Color(0xFF5C6BC0))
.clickable {
coroutineScope.launch {
offsetX.animateTo(0f, tween(200))
}
onRename()
},
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Edit,
contentDescription = "Rename",
tint = Color.White,
modifier = Modifier.size(22.dp)
.fillMaxWidth()
.background(bgColor)
.combinedClickable(
enabled = !isCreatingChat,
onClick = onClick,
onLongClick = onLongClick
)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
UserAvatar(
userId = contact.userId,
fileId = null,
displayName = displayName,
size = 48.dp,
fontSize = 18.sp
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = displayName,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (contact.login != null) {
Text(
text = "@${contact.login}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
}
Box(
modifier = Modifier
.fillMaxHeight()
.width(70.dp)
.background(MaterialTheme.colorScheme.error)
.clickable {
coroutineScope.launch {
offsetX.animateTo(0f, tween(200))
}
onRemove()
},
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Delete,
contentDescription = "Remove",
tint = Color.White,
modifier = Modifier.size(22.dp)
if (isCreatingChat) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
}
}
// Foreground content
ContactItemContent(
contact = contact,
isCreatingChat = isCreatingChat,
onClick = onClick,
modifier = Modifier
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragEnd = {
coroutineScope.launch {
val target = if (offsetX.value < -actionWidthPx / 2) -actionWidthPx else 0f
offsetX.animateTo(target, tween(200))
}
},
onHorizontalDrag = { change, dragAmount ->
change.consume()
coroutineScope.launch {
val newValue = (offsetX.value + dragAmount).coerceIn(-actionWidthPx, 0f)
offsetX.snapTo(newValue)
}
}
)
}
)
}
HorizontalDivider(
@ -279,64 +283,6 @@ private fun SwipeableContactItem(
)
}
@Composable
private fun ContactItemContent(
contact: ContactInfoDto,
isCreatingChat: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val displayName = contact.customName
?: contact.fullName
?: contact.login?.let { "@$it" }
?: "User"
Row(
modifier = modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.clickable(enabled = !isCreatingChat, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
UserAvatar(
userId = contact.userId,
fileId = null,
displayName = displayName,
size = 48.dp,
fontSize = 18.sp
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = displayName,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (contact.login != null) {
Text(
text = "@${contact.login}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
}
if (isCreatingChat) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
}
}
}
@Composable
private fun AddContactDialog(
query: String,

View File

@ -1,7 +1,9 @@
package org.yobble.messenger.presentation.main
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@ -12,9 +14,11 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material3.*
import androidx.compose.runtime.*
@ -72,6 +76,16 @@ fun HomeScreen(
val selectedTab = pagerState.currentPage
val density = LocalDensity.current
var navBarHeightDp by remember { mutableStateOf(0.dp) }
var selectedChatIds by remember { mutableStateOf(setOf<String>()) }
var selectedContactIds by remember { mutableStateOf(setOf<String>()) }
var deleteContactIds by remember { mutableStateOf(setOf<String>()) }
var renameContactId by remember { mutableStateOf<String?>(null) }
// Clear selection when switching tabs
LaunchedEffect(selectedTab) {
selectedChatIds = emptySet()
selectedContactIds = emptySet()
}
LifecycleResumeEffect(Unit) {
viewModel.loadChats()
@ -87,6 +101,24 @@ fun HomeScreen(
}
}
val selectionCount = selectedChatIds.size + selectedContactIds.size
val inSelectionMode = selectionCount > 0
// Back button: clear selection → go to Chats tab → exit
BackHandler(enabled = inSelectionMode || selectedTab != 0) {
when {
inSelectionMode -> {
selectedChatIds = emptySet()
selectedContactIds = emptySet()
}
selectedTab != 0 -> {
coroutineScope.launch {
pagerState.animateScrollToPage(0)
}
}
}
}
Scaffold(
snackbarHost = {
SnackbarHost(
@ -95,24 +127,69 @@ fun HomeScreen(
)
},
topBar = {
TopAppBar(
title = {
Text(
tabs[selectedTab].label,
fontWeight = FontWeight.Bold
if (inSelectionMode) {
TopAppBar(
title = {
Text(
"$selectionCount",
fontWeight = FontWeight.Bold
)
},
navigationIcon = {
IconButton(onClick = {
selectedChatIds = emptySet()
selectedContactIds = emptySet()
}) {
Icon(Icons.Default.Close, contentDescription = "Cancel")
}
},
actions = {
if (selectedContactIds.size == 1 && selectedChatIds.isEmpty()) {
IconButton(onClick = {
renameContactId = selectedContactIds.first()
selectedContactIds = emptySet()
}) {
Icon(Icons.Default.Edit, contentDescription = "Rename")
}
}
IconButton(onClick = {
selectedChatIds.forEach { viewModel.deleteChat(it) }
selectedChatIds = emptySet()
if (selectedContactIds.isNotEmpty()) {
deleteContactIds = selectedContactIds
selectedContactIds = emptySet()
}
}) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
)
},
actions = {
IconButton(onClick = onNavigateToSearch) {
Icon(Icons.Default.Search, contentDescription = "Search")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
)
)
} else {
TopAppBar(
title = {
Text(
tabs[selectedTab].label,
fontWeight = FontWeight.Bold
)
},
actions = {
IconButton(onClick = onNavigateToSearch) {
Icon(Icons.Default.Search, contentDescription = "Search")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
)
)
}
},
contentWindowInsets = WindowInsets(0)
) { padding ->
@ -131,6 +208,24 @@ fun HomeScreen(
tab = tabs[page],
uiState = uiState,
onNavigateToChat = onNavigateToChat,
selectedChatIds = selectedChatIds,
onToggleChatSelection = { chatId ->
selectedChatIds = if (chatId in selectedChatIds)
selectedChatIds - chatId
else
selectedChatIds + chatId
},
selectedContactIds = selectedContactIds,
onToggleContactSelection = { contactId ->
selectedContactIds = if (contactId in selectedContactIds)
selectedContactIds - contactId
else
selectedContactIds + contactId
},
deleteContactIds = deleteContactIds,
onDeleteContactsHandled = { deleteContactIds = emptySet() },
renameContactId = renameContactId,
onRenameContactHandled = { renameContactId = null },
onLoadMore = viewModel::loadMore,
onNavigateToPrivacy = onNavigateToPrivacy,
onNavigateToSessions = onNavigateToSessions,
@ -152,6 +247,24 @@ fun HomeScreen(
tab = tabs[selectedTab],
uiState = uiState,
onNavigateToChat = onNavigateToChat,
selectedChatIds = selectedChatIds,
onToggleChatSelection = { chatId ->
selectedChatIds = if (chatId in selectedChatIds)
selectedChatIds - chatId
else
selectedChatIds + chatId
},
selectedContactIds = selectedContactIds,
onToggleContactSelection = { contactId ->
selectedContactIds = if (contactId in selectedContactIds)
selectedContactIds - contactId
else
selectedContactIds + contactId
},
deleteContactIds = deleteContactIds,
onDeleteContactsHandled = { deleteContactIds = emptySet() },
renameContactId = renameContactId,
onRenameContactHandled = { renameContactId = null },
onLoadMore = viewModel::loadMore,
onNavigateToPrivacy = onNavigateToPrivacy,
onNavigateToSessions = onNavigateToSessions,
@ -237,6 +350,14 @@ private fun TabContent(
tab: HomeTab,
uiState: HomeUiState,
onNavigateToChat: (String) -> Unit,
selectedChatIds: Set<String>,
onToggleChatSelection: (String) -> Unit,
selectedContactIds: Set<String>,
onToggleContactSelection: (String) -> Unit,
deleteContactIds: Set<String>,
onDeleteContactsHandled: () -> Unit,
renameContactId: String?,
onRenameContactHandled: () -> Unit,
onLoadMore: () -> Unit,
onNavigateToPrivacy: () -> Unit,
onNavigateToSessions: () -> Unit,
@ -253,12 +374,20 @@ private fun TabContent(
HomeTab.CHATS -> ChatsContent(
uiState = uiState,
onNavigateToChat = onNavigateToChat,
selectedChatIds = selectedChatIds,
onToggleChatSelection = onToggleChatSelection,
onLoadMore = onLoadMore,
bottomPadding = bottomPadding
)
HomeTab.CONTACTS -> ContactsScreen(
onNavigateToChat = onNavigateToChat,
bottomPadding = bottomPadding
bottomPadding = bottomPadding,
selectedContactIds = selectedContactIds,
onToggleContactSelection = onToggleContactSelection,
deleteContactIds = deleteContactIds,
onDeleteContactsHandled = onDeleteContactsHandled,
renameContactId = renameContactId,
onRenameContactHandled = onRenameContactHandled
)
HomeTab.SETTINGS -> SettingsScreen(
onNavigateToPrivacy = onNavigateToPrivacy,
@ -279,6 +408,8 @@ private fun TabContent(
private fun ChatsContent(
uiState: HomeUiState,
onNavigateToChat: (String) -> Unit,
selectedChatIds: Set<String>,
onToggleChatSelection: (String) -> Unit,
onLoadMore: () -> Unit,
bottomPadding: androidx.compose.ui.unit.Dp
) {
@ -330,7 +461,16 @@ private fun ChatsContent(
items(uiState.chats, key = { it.chatId }) { chat ->
ChatListItem(
chat = chat,
onClick = { onNavigateToChat(chat.chatId) }
isSelected = chat.chatId in selectedChatIds,
inSelectionMode = selectedChatIds.isNotEmpty(),
onClick = {
if (selectedChatIds.isNotEmpty()) {
onToggleChatSelection(chat.chatId)
} else {
onNavigateToChat(chat.chatId)
}
},
onLongClick = { onToggleChatSelection(chat.chatId) }
)
}
if (uiState.isLoading && uiState.chats.isNotEmpty()) {
@ -353,25 +493,37 @@ private fun ChatsContent(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ChatListItem(
chat: PrivateChatListItemDto,
onClick: () -> Unit
isSelected: Boolean,
inSelectionMode: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit
) {
val chatData = chat.chatData
val displayName = chatData?.customName
?: chatData?.fullName
?: chatData?.login?.let { "@$it" }
?: chatData?.login
?: chat.chatType.replaceFirstChar { it.uppercase() }
val isVerified = chatData?.isVerified == true
val rating = chatData?.rating?.rating
val lastMessageText = chat.lastMessage?.content ?: ""
val time = formatUtcToLocalTime(chat.lastMessage?.createdAt)
val bgColor = if (isSelected)
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
else
Color.Transparent
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.background(bgColor)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick
)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
@ -410,20 +562,6 @@ private fun ChatListItem(
modifier = Modifier.size(16.dp)
)
}
if (rating != null) {
Spacer(modifier = Modifier.width(6.dp))
Icon(
Icons.Default.Star,
contentDescription = "Rating",
tint = Color(0xFFFFC107),
modifier = Modifier.size(14.dp)
)
Text(
text = String.format("%.1f", rating),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.width(8.dp))
Text(

View File

@ -160,6 +160,24 @@ class HomeViewModel @Inject constructor(
}
}
fun deleteChat(chatId: String) {
viewModelScope.launch {
when (chatRepository.deleteChat(chatId)) {
is NetworkResult.Success -> {
_uiState.update { state ->
state.copy(chats = state.chats.filter { it.chatId != chatId })
}
}
is NetworkResult.Error -> {
_events.emit(HomeEvent.ShowError("Failed to delete chat"))
}
is NetworkResult.Exception -> {
_events.emit(HomeEvent.ShowError("Connection error"))
}
}
}
}
fun logout() {
socketManager.disconnect()
authRepository.logout()

View File

@ -1,9 +1,14 @@
package org.yobble.messenger.presentation.navigation
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import org.yobble.messenger.presentation.common.SwipeBackContainer
import org.yobble.messenger.presentation.auth.code.CodeVerificationScreen
import org.yobble.messenger.presentation.auth.login.LoginScreen
import org.yobble.messenger.presentation.auth.register.RegisterScreen
@ -42,9 +47,28 @@ fun AppNavGraph(
navController: NavHostController,
startDestination: String
) {
val animDuration = 300
NavHost(
navController = navController,
startDestination = startDestination
startDestination = startDestination,
enterTransition = {
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(animDuration)
)
},
exitTransition = {
ExitTransition.None
},
popEnterTransition = {
EnterTransition.None
},
popExitTransition = {
slideOutOfContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(animDuration)
)
}
) {
composable(Routes.LOGIN) {
LoginScreen(
@ -62,63 +86,45 @@ fun AppNavGraph(
)
}
composable(Routes.REGISTER) {
RegisterScreen(
onNavigateBack = {
navController.popBackStack()
},
onRegisterSuccess = {
navController.popBackStack(Routes.LOGIN, false)
}
)
SwipeBackContainer(onBack = { navController.popBackStack() }) {
RegisterScreen(
onNavigateBack = { navController.popBackStack() },
onRegisterSuccess = { navController.popBackStack(Routes.LOGIN, false) }
)
}
}
composable(Routes.CODE_VERIFICATION) { backStackEntry ->
val login = backStackEntry.arguments?.getString("login") ?: ""
CodeVerificationScreen(
login = login,
onNavigateBack = {
navController.popBackStack()
},
onVerifySuccess = {
navController.navigate(Routes.HOME) {
popUpTo(0) { inclusive = true }
SwipeBackContainer(onBack = { navController.popBackStack() }) {
CodeVerificationScreen(
login = login,
onNavigateBack = { navController.popBackStack() },
onVerifySuccess = {
navController.navigate(Routes.HOME) {
popUpTo(0) { inclusive = true }
}
}
}
)
)
}
}
composable(Routes.HOME) {
HomeScreen(
onNavigateToChat = { chatId ->
navController.navigate(Routes.chat(chatId))
},
onNavigateToPrivacy = {
navController.navigate(Routes.PRIVACY)
},
onNavigateToSessions = {
navController.navigate(Routes.SESSIONS)
},
onNavigateToChangePassword = {
navController.navigate(Routes.CHANGE_PASSWORD)
},
onNavigateToBlacklist = {
navController.navigate(Routes.BLACKLIST)
},
onNavigateToStorage = {
navController.navigate(Routes.STORAGE)
},
onNavigateToSearch = {
navController.navigate(Routes.SEARCH)
},
onNavigateToProfile = {
navController.navigate(Routes.MY_PROFILE)
},
onNavigateToPrivacy = { navController.navigate(Routes.PRIVACY) },
onNavigateToSessions = { navController.navigate(Routes.SESSIONS) },
onNavigateToChangePassword = { navController.navigate(Routes.CHANGE_PASSWORD) },
onNavigateToBlacklist = { navController.navigate(Routes.BLACKLIST) },
onNavigateToStorage = { navController.navigate(Routes.STORAGE) },
onNavigateToSearch = { navController.navigate(Routes.SEARCH) },
onNavigateToProfile = { navController.navigate(Routes.MY_PROFILE) },
onAccountSwitched = {
navController.navigate(Routes.HOME) {
popUpTo(0) { inclusive = true }
}
},
onAddAccount = {
navController.navigate(Routes.LOGIN)
},
onAddAccount = { navController.navigate(Routes.LOGIN) },
onLogout = {
navController.navigate(Routes.LOGIN) {
popUpTo(0) { inclusive = true }
@ -127,79 +133,61 @@ fun AppNavGraph(
)
}
composable(Routes.USER_PROFILE) {
ProfileScreen(
onNavigateBack = {
navController.popBackStack()
},
onNavigateToChat = { chatId ->
navController.navigate(Routes.chat(chatId))
}
)
SwipeBackContainer(onBack = { navController.popBackStack() }) {
ProfileScreen(
onNavigateBack = { navController.popBackStack() },
onNavigateToChat = { chatId -> navController.navigate(Routes.chat(chatId)) }
)
}
}
composable(Routes.CHAT) {
ChatScreen(
onNavigateBack = {
navController.popBackStack()
},
onNavigateToProfile = { userId ->
navController.navigate(Routes.userProfile(userId))
}
)
SwipeBackContainer(onBack = { navController.popBackStack() }) {
ChatScreen(
onNavigateBack = { navController.popBackStack() },
onNavigateToProfile = { userId -> navController.navigate(Routes.userProfile(userId)) }
)
}
}
composable(Routes.PRIVACY) {
PrivacyScreen(
onNavigateBack = {
navController.popBackStack()
}
)
SwipeBackContainer(onBack = { navController.popBackStack() }) {
PrivacyScreen(onNavigateBack = { navController.popBackStack() })
}
}
composable(Routes.SESSIONS) {
SessionsScreen(
onNavigateBack = {
navController.popBackStack()
}
)
SwipeBackContainer(onBack = { navController.popBackStack() }) {
SessionsScreen(onNavigateBack = { navController.popBackStack() })
}
}
composable(Routes.CHANGE_PASSWORD) {
ChangePasswordScreen(
onNavigateBack = {
navController.popBackStack()
}
)
SwipeBackContainer(onBack = { navController.popBackStack() }) {
ChangePasswordScreen(onNavigateBack = { navController.popBackStack() })
}
}
composable(Routes.BLACKLIST) {
BlacklistScreen(
onNavigateBack = {
navController.popBackStack()
}
)
SwipeBackContainer(onBack = { navController.popBackStack() }) {
BlacklistScreen(onNavigateBack = { navController.popBackStack() })
}
}
composable(Routes.SEARCH) {
SearchScreen(
onNavigateBack = {
navController.popBackStack()
},
onNavigateToProfile = { userId ->
navController.navigate(Routes.userProfile(userId))
}
)
SwipeBackContainer(onBack = { navController.popBackStack() }) {
SearchScreen(
onNavigateBack = { navController.popBackStack() },
onNavigateToProfile = { userId -> navController.navigate(Routes.userProfile(userId)) }
)
}
}
composable(Routes.STORAGE) {
StorageScreen(
onNavigateBack = {
navController.popBackStack()
}
)
SwipeBackContainer(onBack = { navController.popBackStack() }) {
StorageScreen(onNavigateBack = { navController.popBackStack() })
}
}
composable(Routes.MY_PROFILE) {
ProfileScreen(
onNavigateBack = {
navController.popBackStack()
},
onNavigateToChat = { chatId ->
navController.navigate(Routes.chat(chatId))
}
)
SwipeBackContainer(onBack = { navController.popBackStack() }) {
ProfileScreen(
onNavigateBack = { navController.popBackStack() },
onNavigateToChat = { chatId -> navController.navigate(Routes.chat(chatId)) }
)
}
}
}
}

View File

@ -0,0 +1,285 @@
package org.yobble.messenger.presentation.settings
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.NetworkCheck
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.yobble.messenger.data.local.CacheStats
private val ColorImages = Color(0xFF42A5F5)
private val ColorNetwork = Color(0xFF66BB6A)
private val ColorOther = Color(0xFFFFA726)
private val ColorEmpty = Color(0xFFE0E0E0)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StorageScreen(
onNavigateBack: () -> Unit,
viewModel: StorageViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Storage", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
)
)
}
) { padding ->
if (uiState.isLoading) {
Box(
modifier = Modifier.fillMaxSize().padding(padding),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(24.dp))
CachePieChart(stats = uiState.stats)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = formatSize(uiState.stats.totalBytes),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Text(
text = "Total cache",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
// Legend + breakdown
CacheCategory(
color = ColorImages,
icon = Icons.Default.Image,
title = "Images",
size = uiState.stats.imagesBytes,
onClear = viewModel::clearImages,
isClearing = uiState.isClearing
)
CacheCategory(
color = ColorNetwork,
icon = Icons.Default.NetworkCheck,
title = "Network cache",
size = uiState.stats.networkBytes,
onClear = viewModel::clearNetwork,
isClearing = uiState.isClearing
)
CacheCategory(
color = ColorOther,
icon = Icons.Default.FolderOpen,
title = "Other",
size = uiState.stats.otherBytes,
onClear = null,
isClearing = uiState.isClearing
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = viewModel::clearAll,
enabled = !uiState.isClearing && uiState.stats.totalBytes > 0,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
),
shape = RoundedCornerShape(12.dp)
) {
if (uiState.isClearing) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onError
)
} else {
Icon(
Icons.Default.DeleteSweep,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Clear all cache")
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
@Composable
private fun CachePieChart(
stats: CacheStats,
modifier: Modifier = Modifier
) {
val total = stats.totalBytes.toFloat()
val animationProgress by animateFloatAsState(
targetValue = if (total > 0) 1f else 0f,
animationSpec = tween(800),
label = "pie"
)
val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant
Box(
modifier = modifier.size(180.dp),
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.size(160.dp)) {
val strokeWidth = 28.dp.toPx()
val arcSize = Size(size.width - strokeWidth, size.height - strokeWidth)
val topLeft = Offset(strokeWidth / 2, strokeWidth / 2)
val style = Stroke(width = strokeWidth, cap = StrokeCap.Butt)
if (total == 0f) {
drawArc(
color = surfaceVariant,
startAngle = 0f,
sweepAngle = 360f,
useCenter = false,
topLeft = topLeft,
size = arcSize,
style = style
)
return@Canvas
}
val segments = listOf(
stats.imagesBytes to ColorImages,
stats.networkBytes to ColorNetwork,
stats.otherBytes to ColorOther
).filter { it.first > 0 }
var startAngle = -90f
segments.forEach { (bytes, color) ->
val sweep = (bytes / total) * 360f * animationProgress
drawArc(
color = color,
startAngle = startAngle,
sweepAngle = sweep,
useCenter = false,
topLeft = topLeft,
size = arcSize,
style = style
)
startAngle += sweep
}
}
}
}
@Composable
private fun CacheCategory(
color: Color,
icon: ImageVector,
title: String,
size: Long,
onClear: (() -> Unit)?,
isClearing: Boolean
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Canvas(modifier = Modifier.size(12.dp)) {
drawCircle(color = color)
}
Spacer(modifier = Modifier.width(12.dp))
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
Text(
text = formatSize(size),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (onClear != null && size > 0) {
Spacer(modifier = Modifier.width(8.dp))
TextButton(
onClick = onClear,
enabled = !isClearing,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)
) {
Text("Clear", fontSize = 12.sp)
}
}
}
}
private fun formatSize(bytes: Long): String {
return when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> String.format("%.1f KB", bytes / 1024.0)
bytes < 1024 * 1024 * 1024 -> String.format("%.1f MB", bytes / (1024.0 * 1024.0))
else -> String.format("%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0))
}
}

View File

@ -0,0 +1,68 @@
package org.yobble.messenger.presentation.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.yobble.messenger.data.local.CacheManager
import org.yobble.messenger.data.local.CacheStats
import javax.inject.Inject
data class StorageUiState(
val stats: CacheStats = CacheStats(),
val isLoading: Boolean = true,
val isClearing: Boolean = false
)
@HiltViewModel
class StorageViewModel @Inject constructor(
private val cacheManager: CacheManager
) : ViewModel() {
private val _uiState = MutableStateFlow(StorageUiState())
val uiState: StateFlow<StorageUiState> = _uiState.asStateFlow()
init {
loadStats()
}
fun loadStats() {
viewModelScope.launch(Dispatchers.IO) {
_uiState.update { it.copy(isLoading = true) }
val stats = cacheManager.getCacheStats()
_uiState.update { it.copy(stats = stats, isLoading = false) }
}
}
fun clearImages() {
viewModelScope.launch(Dispatchers.IO) {
_uiState.update { it.copy(isClearing = true) }
cacheManager.clearImageCache()
val stats = cacheManager.getCacheStats()
_uiState.update { it.copy(stats = stats, isClearing = false) }
}
}
fun clearNetwork() {
viewModelScope.launch(Dispatchers.IO) {
_uiState.update { it.copy(isClearing = true) }
cacheManager.clearNetworkCache()
val stats = cacheManager.getCacheStats()
_uiState.update { it.copy(stats = stats, isClearing = false) }
}
}
fun clearAll() {
viewModelScope.launch(Dispatchers.IO) {
_uiState.update { it.copy(isClearing = true) }
cacheManager.clearAllCache()
val stats = cacheManager.getCacheStats()
_uiState.update { it.copy(stats = stats, isClearing = false) }
}
}
}

View File

@ -1,15 +1,39 @@
package org.yobble.messenger.util
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
fun formatUtcToLocalTime(isoString: String?): String {
if (isoString.isNullOrBlank()) return ""
return try {
val zonedUtc = ZonedDateTime.parse(isoString)
val local = zonedUtc.withZoneSameInstant(java.time.ZoneId.systemDefault())
val local = zonedUtc.withZoneSameInstant(ZoneId.systemDefault())
local.format(DateTimeFormatter.ofPattern("HH:mm"))
} catch (_: Exception) {
isoString.substringAfter("T").take(5)
}
}
fun formatLastSeen(secondsAgo: Int?): String {
if (secondsAgo == null) return ""
if (secondsAgo < 60) return "online"
val minutes = secondsAgo / 60
if (minutes < 60) return "last seen ${minutes}m ago"
val hours = minutes / 60
if (hours < 24) return "last seen ${hours}h ago"
val days = hours / 24
if (days == 1) return "last seen yesterday"
if (days < 7) return "last seen ${days}d ago"
val seen = Instant.now().minusSeconds(secondsAgo.toLong())
val seenDate = seen.atZone(ZoneId.systemDefault()).toLocalDate()
return "last seen ${seenDate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy"))}"
}