[fix]
- change fadeIn fadeOut animation - implement swipe back - add last seen - add avatar in chat
This commit is contained in:
parent
23a7e98b4d
commit
8a6035be49
@ -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"
|
||||
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"))}"
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user