[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:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:theme="@style/Theme.Yobble">
|
android:theme="@style/Theme.Yobble">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
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.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
import androidx.compose.material.icons.filled.Star
|
|
||||||
import androidx.compose.material.icons.filled.Verified
|
import androidx.compose.material.icons.filled.Verified
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
@ -35,6 +34,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.yobble.messenger.data.remote.dto.MessageItemDto
|
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
|
import org.yobble.messenger.util.formatUtcToLocalTime
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@ -108,36 +109,38 @@ fun ChatScreen(
|
|||||||
contentWindowInsets = WindowInsets(0),
|
contentWindowInsets = WindowInsets(0),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
CenterAlignedTopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Row(
|
Column(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable {
|
||||||
uiState.otherUserId?.let { onNavigateToProfile(it) }
|
uiState.otherUserId?.let { onNavigateToProfile(it) }
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Text(uiState.chatTitle, fontWeight = FontWeight.Bold)
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
uiState.chatTitle,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 17.sp,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
if (uiState.isVerified) {
|
if (uiState.isVerified) {
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Verified,
|
Icons.Default.Verified,
|
||||||
contentDescription = "Verified",
|
contentDescription = "Verified",
|
||||||
tint = MaterialTheme.colorScheme.onPrimary,
|
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)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val lastSeenText = formatLastSeen(uiState.otherLastSeen)
|
||||||
|
if (lastSeenText.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = String.format("%.1f", uiState.rating),
|
text = lastSeenText,
|
||||||
fontSize = 13.sp,
|
fontSize = 12.sp,
|
||||||
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f)
|
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
|
||||||
|
maxLines = 1
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -147,10 +150,26 @@ fun ChatScreen(
|
|||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
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,
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
titleContentColor = MaterialTheme.colorScheme.onPrimary,
|
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 chatId: String = "",
|
||||||
val chatTitle: String = "Chat",
|
val chatTitle: String = "Chat",
|
||||||
val isVerified: Boolean = false,
|
val isVerified: Boolean = false,
|
||||||
val rating: Double? = null,
|
|
||||||
val canSendMessage: Boolean = true,
|
val canSendMessage: Boolean = true,
|
||||||
val messages: List<MessageItemDto> = emptyList(),
|
val messages: List<MessageItemDto> = emptyList(),
|
||||||
val messageText: String = "",
|
val messageText: String = "",
|
||||||
@ -36,6 +35,8 @@ data class ChatUiState(
|
|||||||
val hasMore: Boolean = false,
|
val hasMore: Boolean = false,
|
||||||
val currentUserId: String = "",
|
val currentUserId: String = "",
|
||||||
val otherUserId: String? = null,
|
val otherUserId: String? = null,
|
||||||
|
val otherAvatarFileId: String? = null,
|
||||||
|
val otherLastSeen: Int? = null,
|
||||||
val scrollToMessageId: String? = null
|
val scrollToMessageId: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -166,7 +167,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
val otherUser = otherMessage?.senderData
|
val otherUser = otherMessage?.senderData
|
||||||
val title = otherUser?.customName
|
val title = otherUser?.customName
|
||||||
?: otherUser?.fullName
|
?: otherUser?.fullName
|
||||||
?: otherUser?.login?.let { "@$it" }
|
?: otherUser?.login
|
||||||
?: _uiState.value.chatTitle
|
?: _uiState.value.chatTitle
|
||||||
val scrollTarget = if (_uiState.value.messages.isEmpty() && !fromCache) savedMessageId else null
|
val scrollTarget = if (_uiState.value.messages.isEmpty() && !fromCache) savedMessageId else null
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
@ -174,8 +175,9 @@ class ChatViewModel @Inject constructor(
|
|||||||
messages = items,
|
messages = items,
|
||||||
chatTitle = title,
|
chatTitle = title,
|
||||||
otherUserId = otherMessage?.senderId,
|
otherUserId = otherMessage?.senderId,
|
||||||
|
otherAvatarFileId = otherUser?.avatars?.current?.fileId,
|
||||||
|
otherLastSeen = otherUser?.lastSeen,
|
||||||
isVerified = otherUser?.isVerified == true,
|
isVerified = otherUser?.isVerified == true,
|
||||||
rating = otherUser?.rating?.rating,
|
|
||||||
canSendMessage = otherUser?.permissions?.youCanSendMessage != false,
|
canSendMessage = otherUser?.permissions?.youCanSendMessage != false,
|
||||||
hasMore = hasMore,
|
hasMore = hasMore,
|
||||||
isLoading = if (fromCache) true else false,
|
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
|
package org.yobble.messenger.presentation.contacts
|
||||||
|
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
@ -20,30 +17,46 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.yobble.messenger.data.remote.dto.ContactInfoDto
|
import org.yobble.messenger.data.remote.dto.ContactInfoDto
|
||||||
import org.yobble.messenger.presentation.common.UserAvatar
|
import org.yobble.messenger.presentation.common.UserAvatar
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ContactsScreen(
|
fun ContactsScreen(
|
||||||
onNavigateToChat: (chatId: String) -> Unit,
|
onNavigateToChat: (chatId: String) -> Unit,
|
||||||
bottomPadding: androidx.compose.ui.unit.Dp,
|
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()
|
viewModel: ContactsViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
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) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.events.collectLatest { event ->
|
viewModel.events.collectLatest { event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
@ -76,6 +89,7 @@ fun ContactsScreen(
|
|||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
containerColor = Color.Transparent,
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = viewModel::showAddDialog,
|
onClick = viewModel::showAddDialog,
|
||||||
@ -148,10 +162,19 @@ fun ContactsScreen(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items(uiState.contacts, key = { it.userId }) { contact ->
|
items(uiState.contacts, key = { it.userId }) { contact ->
|
||||||
SwipeableContactItem(
|
ContactItem(
|
||||||
contact = contact,
|
contact = contact,
|
||||||
|
isSelected = contact.userId in selectedContactIds,
|
||||||
|
inSelectionMode = selectedContactIds.isNotEmpty(),
|
||||||
isCreatingChat = uiState.creatingChatForUserId == contact.userId,
|
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 ?: "") },
|
onRename = { viewModel.showRenameDialog(contact.userId, contact.customName ?: "") },
|
||||||
onRemove = { viewModel.removeContact(contact.userId) }
|
onRemove = { viewModel.removeContact(contact.userId) }
|
||||||
)
|
)
|
||||||
@ -179,123 +202,38 @@ fun ContactsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SwipeableContactItem(
|
private fun ContactItem(
|
||||||
contact: ContactInfoDto,
|
contact: ContactInfoDto,
|
||||||
|
isSelected: Boolean,
|
||||||
|
inSelectionMode: Boolean,
|
||||||
isCreatingChat: Boolean,
|
isCreatingChat: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
onRename: () -> Unit,
|
onRename: () -> Unit,
|
||||||
onRemove: () -> Unit
|
onRemove: () -> Unit
|
||||||
) {
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
val offsetX = remember { Animatable(0f) }
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val actionWidthPx = with(density) { 140.dp.toPx() }
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clipToBounds()
|
|
||||||
) {
|
|
||||||
// Action buttons behind
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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(
|
|
||||||
modifier = Modifier.padding(start = 76.dp),
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant,
|
|
||||||
thickness = 0.5.dp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ContactItemContent(
|
|
||||||
contact: ContactInfoDto,
|
|
||||||
isCreatingChat: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
) {
|
||||||
val displayName = contact.customName
|
val displayName = contact.customName
|
||||||
?: contact.fullName
|
?: contact.fullName
|
||||||
?: contact.login?.let { "@$it" }
|
?: contact.login
|
||||||
?: "User"
|
?: "User"
|
||||||
|
|
||||||
|
val bgColor = if (isSelected)
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||||
|
else
|
||||||
|
Color.Transparent
|
||||||
|
|
||||||
|
Box {
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
.background(bgColor)
|
||||||
.clickable(enabled = !isCreatingChat, onClick = onClick)
|
.combinedClickable(
|
||||||
|
enabled = !isCreatingChat,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick
|
||||||
|
)
|
||||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
@ -335,6 +273,14 @@ private fun ContactItemContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(start = 76.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
package org.yobble.messenger.presentation.main
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
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.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.Search
|
||||||
import androidx.compose.material.icons.filled.Star
|
|
||||||
import androidx.compose.material.icons.filled.Verified
|
import androidx.compose.material.icons.filled.Verified
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
@ -72,6 +76,16 @@ fun HomeScreen(
|
|||||||
val selectedTab = pagerState.currentPage
|
val selectedTab = pagerState.currentPage
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
var navBarHeightDp by remember { mutableStateOf(0.dp) }
|
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) {
|
LifecycleResumeEffect(Unit) {
|
||||||
viewModel.loadChats()
|
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(
|
Scaffold(
|
||||||
snackbarHost = {
|
snackbarHost = {
|
||||||
SnackbarHost(
|
SnackbarHost(
|
||||||
@ -95,6 +127,50 @@ fun HomeScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
topBar = {
|
topBar = {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
@ -113,6 +189,7 @@ fun HomeScreen(
|
|||||||
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
|
actionIconContentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
contentWindowInsets = WindowInsets(0)
|
contentWindowInsets = WindowInsets(0)
|
||||||
) { padding ->
|
) { padding ->
|
||||||
@ -131,6 +208,24 @@ fun HomeScreen(
|
|||||||
tab = tabs[page],
|
tab = tabs[page],
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
onNavigateToChat = onNavigateToChat,
|
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,
|
onLoadMore = viewModel::loadMore,
|
||||||
onNavigateToPrivacy = onNavigateToPrivacy,
|
onNavigateToPrivacy = onNavigateToPrivacy,
|
||||||
onNavigateToSessions = onNavigateToSessions,
|
onNavigateToSessions = onNavigateToSessions,
|
||||||
@ -152,6 +247,24 @@ fun HomeScreen(
|
|||||||
tab = tabs[selectedTab],
|
tab = tabs[selectedTab],
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
onNavigateToChat = onNavigateToChat,
|
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,
|
onLoadMore = viewModel::loadMore,
|
||||||
onNavigateToPrivacy = onNavigateToPrivacy,
|
onNavigateToPrivacy = onNavigateToPrivacy,
|
||||||
onNavigateToSessions = onNavigateToSessions,
|
onNavigateToSessions = onNavigateToSessions,
|
||||||
@ -237,6 +350,14 @@ private fun TabContent(
|
|||||||
tab: HomeTab,
|
tab: HomeTab,
|
||||||
uiState: HomeUiState,
|
uiState: HomeUiState,
|
||||||
onNavigateToChat: (String) -> Unit,
|
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,
|
onLoadMore: () -> Unit,
|
||||||
onNavigateToPrivacy: () -> Unit,
|
onNavigateToPrivacy: () -> Unit,
|
||||||
onNavigateToSessions: () -> Unit,
|
onNavigateToSessions: () -> Unit,
|
||||||
@ -253,12 +374,20 @@ private fun TabContent(
|
|||||||
HomeTab.CHATS -> ChatsContent(
|
HomeTab.CHATS -> ChatsContent(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
onNavigateToChat = onNavigateToChat,
|
onNavigateToChat = onNavigateToChat,
|
||||||
|
selectedChatIds = selectedChatIds,
|
||||||
|
onToggleChatSelection = onToggleChatSelection,
|
||||||
onLoadMore = onLoadMore,
|
onLoadMore = onLoadMore,
|
||||||
bottomPadding = bottomPadding
|
bottomPadding = bottomPadding
|
||||||
)
|
)
|
||||||
HomeTab.CONTACTS -> ContactsScreen(
|
HomeTab.CONTACTS -> ContactsScreen(
|
||||||
onNavigateToChat = onNavigateToChat,
|
onNavigateToChat = onNavigateToChat,
|
||||||
bottomPadding = bottomPadding
|
bottomPadding = bottomPadding,
|
||||||
|
selectedContactIds = selectedContactIds,
|
||||||
|
onToggleContactSelection = onToggleContactSelection,
|
||||||
|
deleteContactIds = deleteContactIds,
|
||||||
|
onDeleteContactsHandled = onDeleteContactsHandled,
|
||||||
|
renameContactId = renameContactId,
|
||||||
|
onRenameContactHandled = onRenameContactHandled
|
||||||
)
|
)
|
||||||
HomeTab.SETTINGS -> SettingsScreen(
|
HomeTab.SETTINGS -> SettingsScreen(
|
||||||
onNavigateToPrivacy = onNavigateToPrivacy,
|
onNavigateToPrivacy = onNavigateToPrivacy,
|
||||||
@ -279,6 +408,8 @@ private fun TabContent(
|
|||||||
private fun ChatsContent(
|
private fun ChatsContent(
|
||||||
uiState: HomeUiState,
|
uiState: HomeUiState,
|
||||||
onNavigateToChat: (String) -> Unit,
|
onNavigateToChat: (String) -> Unit,
|
||||||
|
selectedChatIds: Set<String>,
|
||||||
|
onToggleChatSelection: (String) -> Unit,
|
||||||
onLoadMore: () -> Unit,
|
onLoadMore: () -> Unit,
|
||||||
bottomPadding: androidx.compose.ui.unit.Dp
|
bottomPadding: androidx.compose.ui.unit.Dp
|
||||||
) {
|
) {
|
||||||
@ -330,7 +461,16 @@ private fun ChatsContent(
|
|||||||
items(uiState.chats, key = { it.chatId }) { chat ->
|
items(uiState.chats, key = { it.chatId }) { chat ->
|
||||||
ChatListItem(
|
ChatListItem(
|
||||||
chat = chat,
|
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()) {
|
if (uiState.isLoading && uiState.chats.isNotEmpty()) {
|
||||||
@ -353,25 +493,37 @@ private fun ChatsContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatListItem(
|
private fun ChatListItem(
|
||||||
chat: PrivateChatListItemDto,
|
chat: PrivateChatListItemDto,
|
||||||
onClick: () -> Unit
|
isSelected: Boolean,
|
||||||
|
inSelectionMode: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val chatData = chat.chatData
|
val chatData = chat.chatData
|
||||||
val displayName = chatData?.customName
|
val displayName = chatData?.customName
|
||||||
?: chatData?.fullName
|
?: chatData?.fullName
|
||||||
?: chatData?.login?.let { "@$it" }
|
?: chatData?.login
|
||||||
?: chat.chatType.replaceFirstChar { it.uppercase() }
|
?: chat.chatType.replaceFirstChar { it.uppercase() }
|
||||||
val isVerified = chatData?.isVerified == true
|
val isVerified = chatData?.isVerified == true
|
||||||
val rating = chatData?.rating?.rating
|
|
||||||
val lastMessageText = chat.lastMessage?.content ?: ""
|
val lastMessageText = chat.lastMessage?.content ?: ""
|
||||||
val time = formatUtcToLocalTime(chat.lastMessage?.createdAt)
|
val time = formatUtcToLocalTime(chat.lastMessage?.createdAt)
|
||||||
|
|
||||||
|
val bgColor = if (isSelected)
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||||
|
else
|
||||||
|
Color.Transparent
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onClick)
|
.background(bgColor)
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick
|
||||||
|
)
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
@ -410,20 +562,6 @@ private fun ChatListItem(
|
|||||||
modifier = Modifier.size(16.dp)
|
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))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
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() {
|
fun logout() {
|
||||||
socketManager.disconnect()
|
socketManager.disconnect()
|
||||||
authRepository.logout()
|
authRepository.logout()
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
package org.yobble.messenger.presentation.navigation
|
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.compose.runtime.Composable
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
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.code.CodeVerificationScreen
|
||||||
import org.yobble.messenger.presentation.auth.login.LoginScreen
|
import org.yobble.messenger.presentation.auth.login.LoginScreen
|
||||||
import org.yobble.messenger.presentation.auth.register.RegisterScreen
|
import org.yobble.messenger.presentation.auth.register.RegisterScreen
|
||||||
@ -42,9 +47,28 @@ fun AppNavGraph(
|
|||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
startDestination: String
|
startDestination: String
|
||||||
) {
|
) {
|
||||||
|
val animDuration = 300
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
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) {
|
composable(Routes.LOGIN) {
|
||||||
LoginScreen(
|
LoginScreen(
|
||||||
@ -62,22 +86,19 @@ fun AppNavGraph(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.REGISTER) {
|
composable(Routes.REGISTER) {
|
||||||
|
SwipeBackContainer(onBack = { navController.popBackStack() }) {
|
||||||
RegisterScreen(
|
RegisterScreen(
|
||||||
onNavigateBack = {
|
onNavigateBack = { navController.popBackStack() },
|
||||||
navController.popBackStack()
|
onRegisterSuccess = { navController.popBackStack(Routes.LOGIN, false) }
|
||||||
},
|
|
||||||
onRegisterSuccess = {
|
|
||||||
navController.popBackStack(Routes.LOGIN, false)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
composable(Routes.CODE_VERIFICATION) { backStackEntry ->
|
composable(Routes.CODE_VERIFICATION) { backStackEntry ->
|
||||||
val login = backStackEntry.arguments?.getString("login") ?: ""
|
val login = backStackEntry.arguments?.getString("login") ?: ""
|
||||||
|
SwipeBackContainer(onBack = { navController.popBackStack() }) {
|
||||||
CodeVerificationScreen(
|
CodeVerificationScreen(
|
||||||
login = login,
|
login = login,
|
||||||
onNavigateBack = {
|
onNavigateBack = { navController.popBackStack() },
|
||||||
navController.popBackStack()
|
|
||||||
},
|
|
||||||
onVerifySuccess = {
|
onVerifySuccess = {
|
||||||
navController.navigate(Routes.HOME) {
|
navController.navigate(Routes.HOME) {
|
||||||
popUpTo(0) { inclusive = true }
|
popUpTo(0) { inclusive = true }
|
||||||
@ -85,40 +106,25 @@ fun AppNavGraph(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
composable(Routes.HOME) {
|
composable(Routes.HOME) {
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
onNavigateToChat = { chatId ->
|
onNavigateToChat = { chatId ->
|
||||||
navController.navigate(Routes.chat(chatId))
|
navController.navigate(Routes.chat(chatId))
|
||||||
},
|
},
|
||||||
onNavigateToPrivacy = {
|
onNavigateToPrivacy = { navController.navigate(Routes.PRIVACY) },
|
||||||
navController.navigate(Routes.PRIVACY)
|
onNavigateToSessions = { navController.navigate(Routes.SESSIONS) },
|
||||||
},
|
onNavigateToChangePassword = { navController.navigate(Routes.CHANGE_PASSWORD) },
|
||||||
onNavigateToSessions = {
|
onNavigateToBlacklist = { navController.navigate(Routes.BLACKLIST) },
|
||||||
navController.navigate(Routes.SESSIONS)
|
onNavigateToStorage = { navController.navigate(Routes.STORAGE) },
|
||||||
},
|
onNavigateToSearch = { navController.navigate(Routes.SEARCH) },
|
||||||
onNavigateToChangePassword = {
|
onNavigateToProfile = { navController.navigate(Routes.MY_PROFILE) },
|
||||||
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 = {
|
onAccountSwitched = {
|
||||||
navController.navigate(Routes.HOME) {
|
navController.navigate(Routes.HOME) {
|
||||||
popUpTo(0) { inclusive = true }
|
popUpTo(0) { inclusive = true }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onAddAccount = {
|
onAddAccount = { navController.navigate(Routes.LOGIN) },
|
||||||
navController.navigate(Routes.LOGIN)
|
|
||||||
},
|
|
||||||
onLogout = {
|
onLogout = {
|
||||||
navController.navigate(Routes.LOGIN) {
|
navController.navigate(Routes.LOGIN) {
|
||||||
popUpTo(0) { inclusive = true }
|
popUpTo(0) { inclusive = true }
|
||||||
@ -127,79 +133,61 @@ fun AppNavGraph(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.USER_PROFILE) {
|
composable(Routes.USER_PROFILE) {
|
||||||
|
SwipeBackContainer(onBack = { navController.popBackStack() }) {
|
||||||
ProfileScreen(
|
ProfileScreen(
|
||||||
onNavigateBack = {
|
onNavigateBack = { navController.popBackStack() },
|
||||||
navController.popBackStack()
|
onNavigateToChat = { chatId -> navController.navigate(Routes.chat(chatId)) }
|
||||||
},
|
|
||||||
onNavigateToChat = { chatId ->
|
|
||||||
navController.navigate(Routes.chat(chatId))
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
composable(Routes.CHAT) {
|
composable(Routes.CHAT) {
|
||||||
|
SwipeBackContainer(onBack = { navController.popBackStack() }) {
|
||||||
ChatScreen(
|
ChatScreen(
|
||||||
onNavigateBack = {
|
onNavigateBack = { navController.popBackStack() },
|
||||||
navController.popBackStack()
|
onNavigateToProfile = { userId -> navController.navigate(Routes.userProfile(userId)) }
|
||||||
},
|
|
||||||
onNavigateToProfile = { userId ->
|
|
||||||
navController.navigate(Routes.userProfile(userId))
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
composable(Routes.PRIVACY) {
|
composable(Routes.PRIVACY) {
|
||||||
PrivacyScreen(
|
SwipeBackContainer(onBack = { navController.popBackStack() }) {
|
||||||
onNavigateBack = {
|
PrivacyScreen(onNavigateBack = { navController.popBackStack() })
|
||||||
navController.popBackStack()
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable(Routes.SESSIONS) {
|
composable(Routes.SESSIONS) {
|
||||||
SessionsScreen(
|
SwipeBackContainer(onBack = { navController.popBackStack() }) {
|
||||||
onNavigateBack = {
|
SessionsScreen(onNavigateBack = { navController.popBackStack() })
|
||||||
navController.popBackStack()
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable(Routes.CHANGE_PASSWORD) {
|
composable(Routes.CHANGE_PASSWORD) {
|
||||||
ChangePasswordScreen(
|
SwipeBackContainer(onBack = { navController.popBackStack() }) {
|
||||||
onNavigateBack = {
|
ChangePasswordScreen(onNavigateBack = { navController.popBackStack() })
|
||||||
navController.popBackStack()
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable(Routes.BLACKLIST) {
|
composable(Routes.BLACKLIST) {
|
||||||
BlacklistScreen(
|
SwipeBackContainer(onBack = { navController.popBackStack() }) {
|
||||||
onNavigateBack = {
|
BlacklistScreen(onNavigateBack = { navController.popBackStack() })
|
||||||
navController.popBackStack()
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable(Routes.SEARCH) {
|
composable(Routes.SEARCH) {
|
||||||
|
SwipeBackContainer(onBack = { navController.popBackStack() }) {
|
||||||
SearchScreen(
|
SearchScreen(
|
||||||
onNavigateBack = {
|
onNavigateBack = { navController.popBackStack() },
|
||||||
navController.popBackStack()
|
onNavigateToProfile = { userId -> navController.navigate(Routes.userProfile(userId)) }
|
||||||
},
|
|
||||||
onNavigateToProfile = { userId ->
|
|
||||||
navController.navigate(Routes.userProfile(userId))
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
composable(Routes.STORAGE) {
|
composable(Routes.STORAGE) {
|
||||||
StorageScreen(
|
SwipeBackContainer(onBack = { navController.popBackStack() }) {
|
||||||
onNavigateBack = {
|
StorageScreen(onNavigateBack = { navController.popBackStack() })
|
||||||
navController.popBackStack()
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable(Routes.MY_PROFILE) {
|
composable(Routes.MY_PROFILE) {
|
||||||
|
SwipeBackContainer(onBack = { navController.popBackStack() }) {
|
||||||
ProfileScreen(
|
ProfileScreen(
|
||||||
onNavigateBack = {
|
onNavigateBack = { navController.popBackStack() },
|
||||||
navController.popBackStack()
|
onNavigateToChat = { chatId -> navController.navigate(Routes.chat(chatId)) }
|
||||||
},
|
|
||||||
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
|
package org.yobble.messenger.util
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
fun formatUtcToLocalTime(isoString: String?): String {
|
fun formatUtcToLocalTime(isoString: String?): String {
|
||||||
if (isoString.isNullOrBlank()) return ""
|
if (isoString.isNullOrBlank()) return ""
|
||||||
return try {
|
return try {
|
||||||
val zonedUtc = ZonedDateTime.parse(isoString)
|
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"))
|
local.format(DateTimeFormatter.ofPattern("HH:mm"))
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
isoString.substringAfter("T").take(5)
|
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