diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3440126..802c11b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -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"> = 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, diff --git a/app/src/main/java/org/yobble/messenger/presentation/common/SwipeBackContainer.kt b/app/src/main/java/org/yobble/messenger/presentation/common/SwipeBackContainer.kt new file mode 100644 index 0000000..7fb4100 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/common/SwipeBackContainer.kt @@ -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() + } + } +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/contacts/ContactsScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/contacts/ContactsScreen.kt index 452f74a..5cb34b3 100644 --- a/app/src/main/java/org/yobble/messenger/presentation/contacts/ContactsScreen.kt +++ b/app/src/main/java/org/yobble/messenger/presentation/contacts/ContactsScreen.kt @@ -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 = emptySet(), + onToggleContactSelection: (String) -> Unit = {}, + deleteContactIds: Set = 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, diff --git a/app/src/main/java/org/yobble/messenger/presentation/main/HomeScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/main/HomeScreen.kt index 3646487..dfbe0a3 100644 --- a/app/src/main/java/org/yobble/messenger/presentation/main/HomeScreen.kt +++ b/app/src/main/java/org/yobble/messenger/presentation/main/HomeScreen.kt @@ -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()) } + var selectedContactIds by remember { mutableStateOf(setOf()) } + var deleteContactIds by remember { mutableStateOf(setOf()) } + var renameContactId by remember { mutableStateOf(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, + onToggleChatSelection: (String) -> Unit, + selectedContactIds: Set, + onToggleContactSelection: (String) -> Unit, + deleteContactIds: Set, + 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, + 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( diff --git a/app/src/main/java/org/yobble/messenger/presentation/main/HomeViewModel.kt b/app/src/main/java/org/yobble/messenger/presentation/main/HomeViewModel.kt index 2db8258..63ca60f 100644 --- a/app/src/main/java/org/yobble/messenger/presentation/main/HomeViewModel.kt +++ b/app/src/main/java/org/yobble/messenger/presentation/main/HomeViewModel.kt @@ -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() diff --git a/app/src/main/java/org/yobble/messenger/presentation/navigation/AppNavGraph.kt b/app/src/main/java/org/yobble/messenger/presentation/navigation/AppNavGraph.kt index 3a02717..d45f130 100644 --- a/app/src/main/java/org/yobble/messenger/presentation/navigation/AppNavGraph.kt +++ b/app/src/main/java/org/yobble/messenger/presentation/navigation/AppNavGraph.kt @@ -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)) } + ) + } } } } diff --git a/app/src/main/java/org/yobble/messenger/presentation/settings/StorageScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/settings/StorageScreen.kt new file mode 100644 index 0000000..4f6c09d --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/settings/StorageScreen.kt @@ -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)) + } +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/settings/StorageViewModel.kt b/app/src/main/java/org/yobble/messenger/presentation/settings/StorageViewModel.kt new file mode 100644 index 0000000..32e66d8 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/settings/StorageViewModel.kt @@ -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 = _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) } + } + } +} diff --git a/app/src/main/java/org/yobble/messenger/util/TimeUtils.kt b/app/src/main/java/org/yobble/messenger/util/TimeUtils.kt index 1fb1440..eb54850 100644 --- a/app/src/main/java/org/yobble/messenger/util/TimeUtils.kt +++ b/app/src/main/java/org/yobble/messenger/util/TimeUtils.kt @@ -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"))}" +}