feat: add all components as regular directories (remove nested .git)

- lastochka-server: removed nested .git, now browsable on GitHub
- lastochka-ios: removed nested .git, now browsable on GitHub
- lastochka-android: added (Kotlin + Jetpack Compose)
- lastochka-desktop: added (Electron + React)
- lastochka-android-compose: removed (replaced by lastochka-android)
- lastochka-ui: updated to latest prototype
- .gitignore: added .gradle/, Pods/, DerivedData/, local.properties

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Anton Budylin
2026-04-17 11:02:36 +03:00
parent b837254b83
commit d266606297
651 changed files with 286580 additions and 9792 deletions
+17
View File
@@ -34,3 +34,20 @@ desktop.ini
# Temporary
*.tmp
*.temp
# Android build caches
.gradle/
.gradle-user-home/
local.properties
*.keystore
# iOS build caches
Pods/
*.xcworkspace/xcuserdata/
DerivedData/
*.xccheckout
*.moved-aside
# Go build
*.test
coverage.out
@@ -1,2 +0,0 @@
#Fri Apr 03 10:41:18 MSK 2026
gradle.version=8.7
@@ -1,2 +0,0 @@
#Fri Apr 03 13:13:31 MSK 2026
java.home=C\:\\Program Files\\Android\\Android Studio\\jbr
Binary file not shown.
@@ -1,124 +0,0 @@
package ru.lastochka.messenger.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* Хедер чата (как в lastochka-ui ChatHeader).
*
* Показывает: аватар, имя, статус (онлайн/typing), кнопки действий.
*/
@Composable
fun ChatHeader(
name: String,
statusText: String?,
isOnline: Boolean,
avatarUrl: String? = null,
onBack: () -> Unit,
onCall: () -> Unit,
onMore: () -> Unit,
onClick: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Кнопка назад
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "Назад",
tint = MaterialTheme.colorScheme.onSurface
)
}
// Аватар + Имя (Кликабельная область)
Row(
modifier = Modifier
.weight(1f)
.clickable(enabled = onClick != null, onClick = onClick ?: {})
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
AvatarLarge(
name = name,
avatarUrl = avatarUrl,
isOnline = isOnline
)
Column(
modifier = Modifier.padding(horizontal = 8.dp)
) {
Text(
text = name,
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (statusText != null) {
Text(
text = statusText,
style = MaterialTheme.typography.bodySmall,
color = if (isOnline)
androidx.compose.ui.graphics.Color(0xFF40C040)
else
MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
// Кнопка звонка
IconButton(onClick = onCall) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = "Позвонить",
tint = MaterialTheme.colorScheme.primary
)
}
// Кнопка видеозвонка
IconButton(onClick = onCall) {
Icon(
imageVector = Icons.Default.Videocam,
contentDescription = "Видеозвонок",
tint = MaterialTheme.colorScheme.primary
)
}
// Меню
IconButton(onClick = onMore) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Ещё",
tint = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
@@ -1,148 +0,0 @@
package ru.lastochka.messenger.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import ru.lastochka.messenger.data.ContactInfo
import ru.lastochka.messenger.ui.theme.LocalBubbleColors
import ru.lastochka.messenger.ui.theme.ReadReceipt
import ru.lastochka.messenger.ui.theme.SentReceipt
import java.text.SimpleDateFormat
import java.util.*
/**
* Элемент чата в списке (как в lastochka-ui Sidebar → ChatItem).
*/
@Composable
fun ChatItem(
contact: ContactInfo,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
val dateFormat = SimpleDateFormat("dd.MM", Locale.getDefault())
val timeString = remember(contact.timestamp) {
contact.timestamp?.let { ts ->
val cal = Calendar.getInstance()
cal.time = ts
val now = Calendar.getInstance()
if (cal.get(Calendar.YEAR) == now.get(Calendar.YEAR) &&
cal.get(Calendar.DAY_OF_YEAR) == now.get(Calendar.DAY_OF_YEAR)) {
timeFormat.format(ts)
} else {
dateFormat.format(ts)
}
} ?: ""
}
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp),
onClick = onClick,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Аватар
AvatarSmall(
name = contact.displayName,
avatarUrl = contact.avatar,
isOnline = contact.isOnline && !contact.isGroup
)
Spacer(modifier = Modifier.width(12.dp))
// Контент
Column(modifier = Modifier.weight(1f)) {
// Имя + время
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = contact.displayName,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (timeString.isNotEmpty()) {
Text(
text = timeString,
style = MaterialTheme.typography.labelSmall,
color = if (contact.unread > 0)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(4.dp))
// Превью сообщения + бейдж непрочитанных
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = contact.lastMessage ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (contact.unread > 0) {
Spacer(modifier = Modifier.width(8.dp))
Badge(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = Color.White
) {
Text(
text = if (contact.unread > 99) "99+" else contact.unread.toString(),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 2.dp)
)
}
}
if (contact.muted) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Default.NotificationsOff,
contentDescription = "Muted",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp)
)
}
}
}
}
}
}
@@ -1,311 +0,0 @@
package ru.lastochka.messenger.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
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.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
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 coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import timber.log.Timber
import ru.lastochka.messenger.LastochkaApp
import ru.lastochka.messenger.data.UiMessage
import ru.lastochka.messenger.ui.theme.LocalBubbleColors
import ru.lastochka.messenger.ui.theme.ReadReceipt
import ru.lastochka.messenger.ui.theme.SentReceipt
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.roundToInt
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MessageBubble(
message: UiMessage,
modifier: Modifier = Modifier,
isFirstInGroup: Boolean = false,
isLastInGroup: Boolean = false,
showSender: Boolean = false,
onLongClick: (() -> Unit)? = null,
onSwipeReply: (() -> Unit)? = null,
onImageClick: ((String) -> Unit)? = null
) {
val bubbleColors = LocalBubbleColors.current
val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
val bgColor = if (message.isOwn) bubbleColors.own else bubbleColors.peer
val textColor = if (message.isOwn) bubbleColors.ownText else bubbleColors.peerText
// State for swipe offset
var offsetX by remember { mutableFloatStateOf(0f) }
val maxOffset = 100f
val bubbleShape = when {
message.isOwn -> when {
isLastInGroup -> RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp, bottomStart = 18.dp, bottomEnd = 4.dp)
else -> RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp, bottomStart = 4.dp, bottomEnd = 4.dp)
}
else -> when {
isLastInGroup -> RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp, bottomStart = 4.dp, bottomEnd = 18.dp)
else -> RoundedCornerShape(topStart = 4.dp, topEnd = 18.dp, bottomStart = 4.dp, bottomEnd = 4.dp)
}
}
val alignment = if (message.isOwn) Alignment.End else Alignment.Start
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = if (isFirstInGroup) 8.dp else 2.dp)
.offset { IntOffset(offsetX.roundToInt(), 0) }
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragEnd = {
if (offsetX > maxOffset / 2) {
onSwipeReply?.invoke()
}
offsetX = 0f
},
onHorizontalDrag = { _, dragAmount ->
if (dragAmount > 0) { // Only allow swipe right
offsetX = (offsetX + dragAmount).coerceIn(0f, maxOffset)
}
}
)
}
.combinedClickable(onClick = {}, onLongClick = onLongClick ?: {}),
horizontalAlignment = alignment
) {
if (showSender && !message.isOwn) {
Text(
text = message.senderName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 4.dp, bottom = 2.dp)
)
}
Box(
modifier = Modifier
.clip(bubbleShape)
.background(bgColor)
.padding(horizontal = 12.dp, vertical = 8.dp)
.widthIn(max = 280.dp)
) {
Column {
// Reply Quote
if (message.replyToContent != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp)
.background(Color(0xFFE0E0E0).copy(alpha = 0.3f), shape = RoundedCornerShape(4.dp))
.padding(start = 8.dp, end = 8.dp, top = 4.dp, bottom = 4.dp)
) {
Row {
Box(
modifier = Modifier
.width(3.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.primary, shape = RoundedCornerShape(2.dp))
)
Spacer(modifier = Modifier.width(6.dp))
Column {
Text("Ответ", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
Text(message.replyToContent, style = MaterialTheme.typography.bodySmall, color = textColor.copy(alpha = 0.7f), maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
}
}
// Image attachment
if (message.hasAttachment) {
val context = LocalContext.current
Timber.d("MessageBubble: hasAttachment=true, seqId=${message.seqId}, attachmentUrl=${message.attachmentUrl?.take(50)}")
if (message.attachmentUrl != null) {
// Determine if this is a data URL (base64 inline) or a server URL
val imageData = if (message.attachmentUrl.startsWith("data:")) {
// Inline base64 image from web client
message.attachmentUrl
} else {
// Server URL — build full download URL with auth headers
val app = context.applicationContext as LastochkaApp
app.tinodeClient.buildFileDownloadUrl(message.attachmentUrl)
}
Timber.d("MessageBubble: loading image, isBase64=${imageData.startsWith("data:")}, isOwn=${message.isOwn}")
SubcomposeAsyncImage(
model = ImageRequest.Builder(context)
.data(imageData)
.crossfade(true)
.listener(
onSuccess = { _, result ->
Timber.d("MessageBubble: image loaded successfully, size=${result.drawable?.intrinsicWidth}x${result.drawable?.intrinsicHeight}")
},
onError = { _, result ->
Timber.e("MessageBubble: image load failed: ${result.throwable?.message}")
}
)
.build(),
contentDescription = "Вложение",
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFFCCCCCC))
.padding(bottom = 4.dp)
.clickable(enabled = onImageClick != null) {
onImageClick?.invoke(imageData)
},
contentScale = ContentScale.Fit,
loading = {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
}
},
error = {
val exc = it.result?.throwable
Box(
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
.clickable { exc?.printStackTrace() },
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Ошибка загрузки",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
exc?.message?.let { msg ->
Text(
text = msg.take(50),
style = MaterialTheme.typography.labelSmall,
color = Color.Red,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
}
)
} else {
// Placeholder: показываем иконку при отправке
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 200.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFFE0E0E0))
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "📷",
fontSize = 32.sp,
modifier = Modifier.padding(bottom = 8.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Отправка...",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
}
}
}
}
// Text content (если есть caption)
if (message.content.isNotBlank() && message.content != " ") {
Text(
text = message.content,
style = MaterialTheme.typography.bodyLarge.copy(color = textColor),
modifier = Modifier.padding(end = 48.dp)
)
}
Row(
modifier = Modifier.align(Alignment.End).padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
if (message.isEdited) {
Text(text = "ред.", style = MaterialTheme.typography.labelSmall, color = textColor.copy(alpha = 0.6f))
}
Text(
text = timeFormat.format(message.timestamp),
style = MaterialTheme.typography.labelSmall,
color = textColor.copy(alpha = 0.6f)
)
if (message.isOwn) {
Icon(
imageVector = if (message.isRead) Icons.Filled.DoneAll else Icons.Filled.Done,
contentDescription = null,
tint = if (message.isRead) ReadReceipt else SentReceipt,
modifier = Modifier.size(14.dp)
)
}
}
}
}
}
}
/**
* Разделитель дат.
*/
@Composable
fun DateDivider(date: Date, modifier: Modifier = Modifier) {
val today = Calendar.getInstance()
val messageDate = Calendar.getInstance().apply { time = date }
val label = when {
today.get(Calendar.YEAR) == messageDate.get(Calendar.YEAR) &&
today.get(Calendar.DAY_OF_YEAR) == messageDate.get(Calendar.DAY_OF_YEAR) -> "Сегодня"
today.get(Calendar.YEAR) == messageDate.get(Calendar.YEAR) &&
today.get(Calendar.DAY_OF_YEAR) - messageDate.get(Calendar.DAY_OF_YEAR) == 1 -> "Вчера"
else -> SimpleDateFormat("dd MMMM yyyy", Locale("ru")).format(date)
}
Box(
modifier = modifier.fillMaxWidth().padding(vertical = 16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(12.dp))
.padding(horizontal = 16.dp, vertical = 6.dp)
)
}
}
@@ -1,126 +0,0 @@
package ru.lastochka.messenger.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
/**
* Поле ввода сообщения (как в lastochka-ui MessageInput).
*
* Стиль: скруглённое, светлый фон, иконка отправки справа.
*/
@Composable
fun MessageInput(
text: String,
onTextChanged: (String) -> Unit,
onSend: () -> Unit,
onAttach: () -> Unit,
modifier: Modifier = Modifier,
placeholder: String = "Сообщение…",
replyToMessage: ru.lastochka.messenger.data.UiMessage? = null
) {
var isFocused by remember { mutableStateOf(false) }
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Кнопка скрепки
IconButton(
onClick = onAttach,
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = Icons.Default.AttachFile,
contentDescription = "Прикрепить",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp)
)
}
Spacer(modifier = Modifier.width(8.dp))
// Поле ввода
Surface(
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = 0.dp
) {
BasicTextField(
value = text,
onValueChange = onTextChanged,
textStyle = TextStyle(
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.bodyLarge.fontSize
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.onFocusChanged { isFocused = it.isFocused },
decorationBox = { innerTextField ->
Box {
if (text.isEmpty()) {
Text(
text = placeholder,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
innerTextField()
}
},
maxLines = 6
)
}
Spacer(modifier = Modifier.width(8.dp))
// Кнопка отправки / микрофон
IconButton(
onClick = {
if (text.isNotBlank()) {
onSend()
}
},
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(
if (text.isNotBlank())
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.surfaceVariant
)
) {
Icon(
imageVector = if (text.isNotBlank())
Icons.Default.Send
else
Icons.Default.Mic,
contentDescription = if (text.isNotBlank()) "Отправить" else "Голосовое",
tint = if (text.isNotBlank())
Color.White
else
MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
}
}
@@ -1,163 +0,0 @@
package ru.lastochka.messenger.ui.screens.chatlist
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import ru.lastochka.messenger.R
import ru.lastochka.messenger.data.ContactInfo
import ru.lastochka.messenger.ui.components.ChatItem
import ru.lastochka.messenger.viewmodel.ChatListViewModel
/**
* Список чатов (как в lastochka-ui Sidebar).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatListScreen(
onChatClick: (String, String) -> Unit,
onNewChat: () -> Unit,
onProfile: () -> Unit,
onLogout: () -> Unit,
viewModel: ChatListViewModel = hiltViewModel()
) {
val contacts by viewModel.filteredContacts.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val error by viewModel.error.collectAsState()
// Handle session expired
LaunchedEffect(error) {
if (error == "SESSION_EXPIRED") {
viewModel.clearError()
onLogout()
}
}
Scaffold(
topBar = {
Column {
TopAppBar(
title = { Text("Чаты") },
actions = {
IconButton(onClick = onProfile) {
Icon(Icons.Default.Settings, "Настройки")
}
}
)
// Search Bar
OutlinedTextField(
value = searchQuery,
onValueChange = viewModel::onSearchQueryChanged,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
placeholder = { Text("Поиск") },
leadingIcon = { Icon(Icons.Default.Search, null) },
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { viewModel.onSearchQueryChanged("") }) {
Icon(Icons.Default.ClearAll, "Очистить")
}
}
},
singleLine = true,
shape = RoundedCornerShape(12.dp),
textStyle = MaterialTheme.typography.bodyMedium
)
}
},
floatingActionButton = {
FloatingActionButton(
onClick = onNewChat,
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(
Icons.Default.Edit,
contentDescription = stringResource(R.string.chat_new),
tint = Color.White
)
}
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(padding)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = MaterialTheme.colorScheme.primary
)
} else if (contacts.isEmpty()) {
// Пустое состояние
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "💬",
fontSize = 64.sp,
modifier = Modifier.padding(bottom = 16.dp)
)
Text(
text = stringResource(R.string.chats_empty),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = stringResource(R.string.chats_empty_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp)
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(
items = contacts,
key = { it.topicName }
) { contact ->
ChatItem(
contact = contact,
onClick = { onChatClick(contact.topicName, contact.displayName) }
)
}
}
}
// Error snackbar
if (error != null) {
Snackbar(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp),
containerColor = MaterialTheme.colorScheme.error,
contentColor = Color.White
) {
Text(error!!)
}
}
}
}
}
@@ -1,125 +0,0 @@
package ru.lastochka.messenger.ui.screens.settings
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.launch
import ru.lastochka.messenger.viewmodel.ProfileViewModel
/**
* Экран редактирования профиля.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileScreen(
onNavigateBack: () -> Unit,
viewModel: ProfileViewModel = hiltViewModel()
) {
val name by viewModel.name.collectAsState()
val bio by viewModel.bio.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Профиль") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Назад")
}
},
actions = {
IconButton(
onClick = {
scope.launch {
viewModel.saveProfile()
onNavigateBack()
}
},
enabled = !isLoading
) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else {
Icon(Icons.Default.Check, "Сохранить")
}
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Аватар
Surface(
modifier = Modifier.size(100.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = name.take(1).uppercase(),
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Имя
OutlinedTextField(
value = name,
onValueChange = viewModel::updateName,
label = { Text("Имя") },
placeholder = { Text("Ваше имя") },
leadingIcon = { Icon(Icons.Default.Person, null) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words)
)
Spacer(modifier = Modifier.height(16.dp))
// Био
OutlinedTextField(
value = bio,
onValueChange = viewModel::updateBio,
label = { Text("О себе") },
placeholder = { Text("Расскажите о себе") },
leadingIcon = { Icon(Icons.Default.Info, null) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences)
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Ваш профиль виден другим пользователям",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}
@@ -1,213 +0,0 @@
package ru.lastochka.messenger.ui.screens.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/**
* Экран настроек.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onNavigateToProfile: () -> Unit,
onLogout: () -> Unit
) {
var showLogoutDialog by remember { mutableStateOf(false) }
if (showLogoutDialog) {
AlertDialog(
onDismissRequest = { showLogoutDialog = false },
title = { Text("Выход") },
text = { Text("Вы действительно хотите выйти из аккаунта?") },
confirmButton = {
TextButton(
onClick = {
showLogoutDialog = false
// onLogout → MainActivity → sessionRepository.logout() → authState → Unauthenticated
onLogout()
}
) {
Text("Выйти", color = Color(0xFFEF5350), fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { showLogoutDialog = false }) {
Text("Отмена")
}
}
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Настройки") }
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
LazyColumn {
// Профиль
item {
SettingsSectionHeader("Профиль")
}
item {
SettingsItem(
icon = Icons.Default.Person,
title = "Изменить профиль",
subtitle = "Имя, фото, био",
onClick = onNavigateToProfile
)
}
// Уведомления
item {
SettingsSectionHeader("Уведомления")
}
item {
var notificationsEnabled by remember { mutableStateOf(true) }
SettingsItem(
icon = Icons.Default.Notifications,
title = "Уведомления",
subtitle = "Звук и вибрация",
trailing = {
Switch(
checked = notificationsEnabled,
onCheckedChange = { notificationsEnabled = it }
)
},
onClick = { notificationsEnabled = !notificationsEnabled }
)
}
// Внешний вид
item {
SettingsSectionHeader("Внешний вид")
}
item {
var isDarkTheme by remember { mutableStateOf(false) }
SettingsItem(
icon = Icons.Default.DarkMode,
title = "Тёмная тема",
trailing = {
Switch(
checked = isDarkTheme,
onCheckedChange = { isDarkTheme = it }
)
},
onClick = { isDarkTheme = !isDarkTheme }
)
}
// О приложении
item {
SettingsSectionHeader("О приложении")
}
item {
SettingsItem(
icon = Icons.Default.Info,
title = "Версия",
subtitle = "1.0.0 (Alpha)"
)
}
item {
SettingsItem(
icon = Icons.Default.Code,
title = "Лицензия",
subtitle = "GPL v3"
)
}
// Выход
item {
Spacer(modifier = Modifier.height(16.dp))
}
item {
Button(
onClick = { showLogoutDialog = true },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(48.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFEF5350)
)
) {
Icon(Icons.Default.Logout, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Выйти", fontWeight = FontWeight.Bold)
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
}
}
@Composable
fun SettingsSectionHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
@Composable
fun SettingsItem(
icon: ImageVector,
title: String,
subtitle: String? = null,
trailing: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = onClick != null, onClick = onClick ?: {})
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
trailing?.invoke()
}
}
@@ -1,66 +0,0 @@
package ru.lastochka.messenger.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import ru.lastochka.messenger.data.ChatRepository
import ru.lastochka.messenger.data.ContactInfo
import javax.inject.Inject
/**
* ViewModel для поиска пользователей и создания нового чата.
*/
@HiltViewModel
class NewChatViewModel @Inject constructor(
private val repository: ChatRepository
) : ViewModel() {
private val _searchResults = MutableStateFlow<List<ContactInfo>>(emptyList())
val searchResults: StateFlow<List<ContactInfo>> = _searchResults.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private var searchJob: Job? = null
/**
* Поиск пользователей по запросу с debounce.
*/
fun search(query: String) {
searchJob?.cancel()
if (query.length < 2) {
_searchResults.value = emptyList()
return
}
searchJob = viewModelScope.launch {
delay(400) // Debounce
_isLoading.value = true
try {
val results = repository.searchUsers(query)
_searchResults.value = results
} catch (e: Exception) {
_searchResults.value = emptyList()
} finally {
_isLoading.value = false
}
}
}
/**
* Начать чат с выбранным пользователем.
*/
suspend fun startChat(topicName: String): Result<Unit> {
return repository.startChatWithUser(topicName)
}
fun clearResults() {
_searchResults.value = emptyList()
searchJob?.cancel()
}
}
@@ -1,66 +0,0 @@
package ru.lastochka.messenger.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import ru.lastochka.messenger.data.ChatRepository
import javax.inject.Inject
/**
* ViewModel для экрана профиля.
*/
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val repository: ChatRepository
) : ViewModel() {
private val _name = MutableStateFlow("")
val name: StateFlow<String> = _name.asStateFlow()
private val _bio = MutableStateFlow("")
val bio: StateFlow<String> = _bio.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
init {
loadProfile()
}
private fun loadProfile() {
viewModelScope.launch {
try {
val result = repository.getMyProfile()
if (result.isSuccess) {
val profile = result.getOrNull()
_name.value = profile?.displayName ?: ""
_bio.value = profile?.bio ?: ""
}
} catch (e: Exception) {
// Ошибка загрузки — оставляем пустые значения
}
}
}
fun updateName(newName: String) {
_name.value = newName
}
fun updateBio(newBio: String) {
_bio.value = newBio
}
suspend fun saveProfile() {
_isLoading.value = true
try {
val result = repository.updateProfile(name.value, bio.value)
if (result.isFailure) {
// Handle error if needed
}
} finally {
_isLoading.value = false
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

@@ -1,8 +0,0 @@
## This file is automatically generated by Android Studio.
# Do not modify this file -- YOUR CHANGES WILL BE ERASED!
#
# This file should *NOT* be checked into Version Control Systems,
# as it contains information specific to your local configuration.
#
# Location of the SDK. This is only used by Gradle.
sdk.dir=C\:\\Users\\Dragon\\AppData\\Local\\Android\\Sdk
@@ -9,6 +9,7 @@
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<application
android:name=".LastochkaApp"
@@ -1,9 +1,11 @@
package ru.lastochka.messenger
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.*
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
@@ -14,6 +16,7 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
@@ -31,9 +34,9 @@ import ru.lastochka.messenger.data.SessionRepository
import ru.lastochka.messenger.navigation.Screen
import ru.lastochka.messenger.ui.screens.auth.LoginScreen
import ru.lastochka.messenger.ui.screens.auth.RegisterScreen
import ru.lastochka.messenger.ui.screens.calls.CallsScreen
import ru.lastochka.messenger.ui.screens.chat.ChatScreen
import ru.lastochka.messenger.ui.screens.contact.ContactInfoScreen
import ru.lastochka.messenger.ui.screens.contacts.ContactsScreen
import ru.lastochka.messenger.ui.screens.groups.CreateGroupScreen
import ru.lastochka.messenger.ui.screens.chatlist.ChatListScreen
import ru.lastochka.messenger.ui.screens.newchat.NewChatScreen
@@ -65,7 +68,10 @@ class MainActivity : ComponentActivity() {
splashScreen.setKeepOnScreenCondition { keepSplashOnScreen }
setContent {
LastochkaTheme {
val systemDark = isSystemInDarkTheme()
var darkMode by rememberSaveable { mutableStateOf(systemDark) }
LastochkaTheme(darkTheme = darkMode) {
// AuthState как StateFlow — реагирует на logout/login
val authState by sessionRepository.authState.collectAsState()
val isAuthenticated = authState is AuthState.Authenticated
@@ -93,6 +99,8 @@ class MainActivity : ComponentActivity() {
if (isAuthenticated) {
MainAppScreen(
darkMode = darkMode,
onToggleDarkMode = { darkMode = !darkMode },
onLogoutRequired = {
// Logout через SessionRepository
kotlinx.coroutines.GlobalScope.launch {
@@ -141,7 +149,11 @@ class MainActivity : ComponentActivity() {
}
@Composable
fun MainAppScreen(onLogoutRequired: () -> Unit) {
fun MainAppScreen(
darkMode: Boolean,
onToggleDarkMode: () -> Unit,
onLogoutRequired: () -> Unit
) {
val navController = rememberNavController()
var selectedTab by remember { mutableStateOf(0) }
val chatListViewModel: ChatListViewModel = hiltViewModel()
@@ -154,8 +166,9 @@ class MainActivity : ComponentActivity() {
LaunchedEffect(currentRoute) {
selectedTab = when {
currentRoute?.startsWith(Screen.Chats.route) == true -> 0
currentRoute == Screen.Calls.route -> 1
currentRoute == Screen.Contacts.route -> 1
currentRoute?.startsWith(Screen.Settings.route) == true -> 2
currentRoute?.startsWith(Screen.Profile.route) == true -> 3
else -> selectedTab
}
}
@@ -191,13 +204,13 @@ class MainActivity : ComponentActivity() {
selected = selectedTab == 1,
onClick = {
selectedTab = 1
navController.navigate(Screen.Calls.route) {
popUpTo(Screen.Calls.route) { inclusive = false }
navController.navigate(Screen.Contacts.route) {
popUpTo(Screen.Contacts.route) { inclusive = false }
launchSingleTop = true
}
},
icon = { Icon(Icons.Default.Call, null) },
label = { Text("Звонки", fontWeight = if (selectedTab == 1) FontWeight.Bold else FontWeight.Normal) }
icon = { Icon(Icons.Default.Contacts, null) },
label = { Text("Контакты", fontWeight = if (selectedTab == 1) FontWeight.Bold else FontWeight.Normal) }
)
NavigationBarItem(
selected = selectedTab == 2,
@@ -211,6 +224,18 @@ class MainActivity : ComponentActivity() {
icon = { Icon(Icons.Default.Settings, null) },
label = { Text("Настройки", fontWeight = if (selectedTab == 2) FontWeight.Bold else FontWeight.Normal) }
)
NavigationBarItem(
selected = selectedTab == 3,
onClick = {
selectedTab = 3
navController.navigate(Screen.Profile.route) {
popUpTo(Screen.Profile.route) { inclusive = false }
launchSingleTop = true
}
},
icon = { Icon(Icons.Default.Person, null) },
label = { Text("Профиль", fontWeight = if (selectedTab == 3) FontWeight.Bold else FontWeight.Normal) }
)
}
}
) { padding ->
@@ -228,9 +253,11 @@ class MainActivity : ComponentActivity() {
onNewChat = {
navController.navigate(Screen.NewChat.route)
},
onProfile = {
navController.navigate(Screen.Profile.route)
onCreateGroup = {
navController.navigate(Screen.CreateGroup.route)
},
darkMode = darkMode,
onToggleDarkMode = onToggleDarkMode,
onLogout = onLogoutRequired
)
}
@@ -244,8 +271,8 @@ class MainActivity : ComponentActivity() {
}
)
) { backStackEntry ->
val topicName = backStackEntry.arguments?.getString("topicName") ?: ""
val topicTitleArg = backStackEntry.arguments?.getString("topicTitle") ?: ""
val topicName = Uri.decode(backStackEntry.arguments?.getString("topicName") ?: "")
val topicTitleArg = Uri.decode(backStackEntry.arguments?.getString("topicTitle") ?: "")
// Если title пустой или равен topicName — загружаем настоящее имя из контакта
val effectiveTitle = if (topicTitleArg.isBlank() || topicTitleArg == topicName) {
null // Загрузим из сервера
@@ -268,8 +295,8 @@ class MainActivity : ComponentActivity() {
navArgument("topicTitle") { type = NavType.StringType }
)
) { backStackEntry ->
val topicName = backStackEntry.arguments?.getString("topicName") ?: ""
val topicTitle = backStackEntry.arguments?.getString("topicTitle") ?: ""
val topicName = Uri.decode(backStackEntry.arguments?.getString("topicName") ?: "")
val topicTitle = Uri.decode(backStackEntry.arguments?.getString("topicTitle") ?: "")
ContactInfoScreen(
topicName = topicName,
topicTitle = topicTitle,
@@ -308,9 +335,9 @@ class MainActivity : ComponentActivity() {
)
}
// --- Calls Graph ---
composable(Screen.Calls.route) {
CallsScreen()
// --- Contacts Graph ---
composable(Screen.Contacts.route) {
ContactsScreen()
}
// --- Settings Graph ---
@@ -319,6 +346,9 @@ class MainActivity : ComponentActivity() {
onNavigateToProfile = {
navController.navigate(Screen.Profile.route)
},
onBack = { navController.popBackStack() },
darkMode = darkMode,
onToggleDarkMode = onToggleDarkMode,
onLogout = onLogoutRequired
)
}
@@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.Flow
import ru.lastochka.messenger.data.local.AppDatabase
import ru.lastochka.messenger.data.local.ContactEntity
import ru.lastochka.messenger.data.local.MessageEntity
import java.util.Date
/**
* Repository единая точка доступа к данным (Tinode + Room).
@@ -101,6 +102,22 @@ class ChatRepository(
return tinodeClient.getContacts(subs)
}
suspend fun enrichContactsWithLastMessages(contacts: List<ContactInfo>): List<ContactInfo> {
return contacts.map { contact ->
val latest = database.messageDao().getLatestMessage(contact.topicName) ?: return@map contact
val preview = buildLastMessagePreview(latest)
val prefixedPreview = if (latest.isOwn && preview != "печатает...") {
"Вы: $preview"
} else {
preview
}
contact.copy(
lastMessage = prefixedPreview,
timestamp = Date(latest.timestamp)
)
}
}
suspend fun getTopicTitle(topicName: String): String {
return tinodeClient.getTopicTitle(topicName)
}
@@ -162,8 +179,16 @@ class ChatRepository(
tinodeClient.editMessage(topicName, seqId, newText)
}
suspend fun updateProfile(name: String, bio: String): Result<Unit> {
return tinodeClient.updateProfile(name, bio)
suspend fun updateProfile(name: String, bio: String, photoUrl: String?): Result<Unit> {
return tinodeClient.updateProfile(name, bio, photoUrl)
}
suspend fun updateAvatar(
imageUri: android.net.Uri,
mimeType: String,
fileName: String
): Result<String> {
return tinodeClient.updateAvatar(imageUri, mimeType, fileName)
}
suspend fun getMyProfile(): Result<ru.lastochka.messenger.data.UserProfile> {
@@ -175,4 +200,20 @@ class ChatRepository(
}
val events = tinodeClient.events
private fun buildLastMessagePreview(message: MessageEntity): String {
if (message.hasAttachment) {
val mime = message.attachmentType?.lowercase().orEmpty()
return when {
mime.startsWith("image/") -> "Фото"
mime.startsWith("video/") -> "Видео"
mime.startsWith("audio/") -> "Аудио"
else -> "Файл"
}
}
return message.content
.replace('\n', ' ')
.trim()
.ifBlank { "Сообщение" }
}
}
@@ -74,6 +74,8 @@ class TinodeClient(
// Deferred для me-topic subscriptions
private var meSubsDeferred: CompletableDeferred<List<MetaSub>>? = null
private val meSubsMutex = Mutex()
private var meDescDeferred: CompletableDeferred<MetaDesc>? = null
private val meDescMutex = Mutex()
private val connectMutex = Mutex()
// Deferreds для получения участников топика (группы)
@@ -107,6 +109,13 @@ class TinodeClient(
meSubsDeferred = null
}
}
meDescMutex.withLock {
val currentDeferred = meDescDeferred
if (meta.topic == "me" && meta.desc != null && currentDeferred?.isActive == true) {
currentDeferred.complete(meta.desc)
meDescDeferred = null
}
}
// Обрабатываем участников группы
meta.topic?.let { t ->
val memberDeferred = topicMemberDeferreds[t]
@@ -490,25 +499,54 @@ class TinodeClient(
// Флаг: true — ожидаем результаты после setMeta, false — игнорируем meta от подписки
@Volatile private var fndResultsRequested = false
private fun normalizePhoneDigits(value: String): String = value.replace("[^\\d]".toRegex(), "")
/**
* Определить тег поиска FND по запросу.
* Телефон (начинается с +7/8/7 и 10-11 цифр) "tel:79991234567"
* Иначе "basic:<query>"
* Построить список FND-запросов как в web-prototype:
* - оригинал/normal/basic
* - fn:<token>, name:<token>
* - tel:* для телефонных вариантов
*/
private fun buildFndTag(query: String): String {
val digits = query.replace("[^\\d]".toRegex(), "")
val looksLikePhone = digits.length >= 10 &&
(query.trimStart().startsWith("+") || query.trimStart().first().isDigit())
if (looksLikePhone) {
val normalized = when {
digits.startsWith("8") && digits.length == 11 -> "7" + digits.drop(1)
digits.startsWith("7") && digits.length == 11 -> digits
digits.length == 10 -> "7$digits"
else -> digits
}
return "tel:$normalized"
private fun buildFndQueries(rawQuery: String): List<String> {
val trimmed = rawQuery.trim()
if (trimmed.isBlank()) return emptyList()
val base = if (trimmed.startsWith("@")) trimmed.drop(1) else trimmed
val normalized = base.lowercase(Locale.ROOT)
val phoneDigits = normalizePhoneDigits(base)
val tokens = normalized.split("\\s+".toRegex()).map { it.trim() }.filter { it.isNotBlank() }
val queries = linkedSetOf<String>()
queries += trimmed
queries += normalized
if (normalized.isNotBlank() && !normalized.contains(":") && tokens.size == 1) {
queries += "basic:$normalized"
}
return "basic:$query"
if (!normalized.contains(":")) {
tokens.forEach { token ->
queries += "fn:$token"
queries += "name:$token"
}
}
if (phoneDigits.isNotBlank()) {
queries += phoneDigits
queries += "tel:$phoneDigits"
queries += "tel:+$phoneDigits"
if (phoneDigits.length == 11 && phoneDigits.startsWith("8")) {
val ruE164 = "7${phoneDigits.drop(1)}"
queries += "tel:$ruE164"
queries += "tel:+$ruE164"
} else if (phoneDigits.length == 10 && phoneDigits.startsWith("9")) {
val ruE164 = "7$phoneDigits"
queries += "tel:$ruE164"
queries += "tel:+$ruE164"
}
}
return queries.filter { it.isNotBlank() }
}
/**
@@ -517,39 +555,61 @@ class TinodeClient(
*/
suspend fun searchUsers(query: String): List<ContactInfo> {
if (!awaitConnection()) return emptyList()
if (query.length < 2) return emptyList()
val normalizedQuery = query.trim()
if (normalizedQuery.length < 2) return emptyList()
return fndMutex.withLock {
fndResultsRequested = false
try {
val deferred = CompletableDeferred<List<ContactInfo>>()
fndDeferred = deferred
// Подписываемся на fnd topic (или переподписываемся, если уже подписаны)
val r = httpClient.subscribe("fnd", MetaGetPacket(
desc = MetaGetDesc(),
sub = MetaGetSub()
))
if (r.ctrl?.code !in 200..299) {
fndDeferred = null
// 304 "already subscribed" для fnd — штатный ответ.
if (r.ctrl?.code !in 200..399) {
return@withLock emptyList<ContactInfo>()
}
val tag = buildFndTag(query)
Timber.d("searchUsers: query='$query', tag='$tag'")
val allResults = linkedMapOf<String, ContactInfo>()
val fndQueries = buildFndQueries(normalizedQuery)
Timber.d("searchUsers: query='$normalizedQuery', fndQueries=${fndQueries.size}")
// Устанавливаем поисковый запрос. После этого сервер пришлёт META с результатами.
fndResultsRequested = true
httpClient.setMeta("fnd", MetaSetPacket(
desc = MetaSetDesc(tags = listOf(tag))
))
fndQueries.forEach { tag ->
suspend fun runOneQuery(set: MetaSetPacket) {
val deferred = CompletableDeferred<List<ContactInfo>>()
fndDeferred = deferred
fndResultsRequested = true
try {
val ctrl = httpClient.setMeta("fnd", set)
if (ctrl.ctrl?.code !in 200..399) return
val metaCtrl = runCatching { httpClient.getMeta("fnd", "sub") }.getOrNull()?.ctrl?.code
// 204 = no content для этого запроса, не ждём таймаут.
if (metaCtrl == 204) return
val results = withTimeoutOrNull(1000) { deferred.await() } ?: emptyList()
results.forEach { contact ->
allResults.putIfAbsent(contact.topicName, contact)
}
} finally {
fndResultsRequested = false
fndDeferred = null
}
}
// Ждём результат с таймаутом
withTimeout(6000) { deferred.await() }
// Режим как в web-prototype.
runOneQuery(MetaSetPacket(desc = MetaSetDesc(public = tag)))
// Fallback для серверов, где поиск живёт в desc.tags.
runOneQuery(MetaSetPacket(desc = MetaSetDesc(tags = listOf(tag))))
}
allResults.values.toList()
} catch (e: TimeoutCancellationException) {
Timber.w("searchUsers: timeout for query='$query'")
Timber.w("searchUsers: timeout for query='$normalizedQuery'")
fndDeferred = null
emptyList()
} catch (e: CancellationException) {
// Debounce в ViewModel отменяет предыдущие поисковые jobs — это нормально.
throw e
} catch (e: Exception) {
Timber.e(e, "searchUsers: error")
fndDeferred = null
@@ -599,11 +659,27 @@ class TinodeClient(
suspend fun loadMessagesBefore(topicName: String, beforeSeq: Int, limit: Int): List<DataPacket> {
if (!awaitConnection()) return emptyList()
return try {
// Запрашиваем данные у сервера
// beforeSeq в текущем протоколе клиента не используется для "до seq":
// запрашиваем последние сообщения через since=0 и limit.
val response = httpClient.getData(topicName, since = 0, limit = limit)
// Tinode возвращает meta с данными, но для getData ответ — это meta
// Сообщения приходят через DATA события в event flow
// Поэтому просто возвращаем пустой список — сообщения добавятся через listenForMessages
// Часть серверов может вернуть data внутри meta.data в том же ответе.
response.meta?.data?.forEach { item ->
val seq = item.seq
val content = item.content ?: return@forEach
_events.emit(
TinodeEvent.NewMessage(
DataPacket(
topic = topicName,
from = item.from,
seq = seq,
content = content,
head = item.head,
extra = item.extra,
ts = item.ts
)
)
)
}
emptyList()
} catch (e: Exception) {
emptyList()
@@ -621,8 +697,13 @@ class TinodeClient(
sub = MetaGetSub(),
data = MetaGetData(0, 50)
))
if (r.ctrl?.code in 200..299) Result.success(Unit)
else Result.failure(Exception(r.ctrl?.text ?: "Subscribe failed"))
if (r.ctrl?.code in 200..299) {
// Явно запрашиваем историю: часть серверов не присылает её через sub.get.data.
runCatching { httpClient.getData(topicName, since = 0, limit = 200) }
Result.success(Unit)
} else {
Result.failure(Exception(r.ctrl?.text ?: "Subscribe failed"))
}
} catch (e: Exception) { Result.failure(e) }
}
@@ -932,13 +1013,16 @@ class TinodeClient(
* Обновить профиль пользователя (имя + bio).
* Передаёт displayName в fn и bio в note через me-топик.
*/
suspend fun updateProfile(displayName: String, bio: String): Result<Unit> {
suspend fun updateProfile(displayName: String, bio: String, photoUrl: String?): Result<Unit> {
if (!awaitConnection()) return Result.failure(Exception("Нет подключения к серверу"))
return try {
httpClient.subscribe("me", MetaGetPacket(desc = MetaGetDesc()))
httpClient.setMeta("me", MetaSetPacket(
desc = MetaSetDesc(
public = TheCard(fn = displayName, photo = null),
public = TheCard(
fn = displayName.trim().ifBlank { null },
photo = photoUrl
),
private = PrivateData(note = bio)
)
))
@@ -966,26 +1050,30 @@ class TinodeClient(
suspend fun getMyProfile(): Result<UserProfile> {
if (!awaitConnection()) return Result.failure(Exception("Нет подключения к серверу"))
return try {
val r = httpClient.subscribe("me", MetaGetPacket(
desc = MetaGetDesc(),
sub = null
))
val deferred = CompletableDeferred<MetaDesc>()
meDescMutex.withLock {
meDescDeferred = deferred
}
httpClient.getMeta("me", "desc")
val desc = try {
withTimeout(7_000) { deferred.await() }
} catch (e: Exception) {
meDescMutex.withLock { meDescDeferred = null }
null
}
// Имя хранится в meta.desc.public.fn или meta.public.fn
val displayName = r.meta?.desc?.`public`?.fn
?: r.meta?.`public`?.fn
?: ""
val displayName = desc?.`public`?.fn ?: ""
val bio = (desc?.`private` as? Map<*, *>)?.get("note") as? String
val avatar = desc?.`public`?.photo
// Bio хранится в meta.desc.private.note
val bio = (r.meta?.desc?.`private` as? Map<*, *>)?.get("note") as? String
Result.success(UserProfile(
Result.success(
UserProfile(
uid = myUid ?: "",
displayName = displayName,
avatar = r.meta?.desc?.`public`?.photo
?: r.meta?.`public`?.photo,
avatar = avatar,
bio = bio
))
)
)
} catch (e: Exception) { Result.failure(e) }
}
@@ -993,16 +1081,14 @@ class TinodeClient(
* Обновить аватар пользователя.
* Отправляет base64-изображение через setMeta me-топика.
*/
suspend fun updateAvatar(base64Photo: String): Result<Unit> {
suspend fun updateAvatar(imageUri: android.net.Uri, mimeType: String, fileName: String): Result<String> {
if (!awaitConnection()) return Result.failure(Exception("Нет подключения к серверу"))
return try {
httpClient.setMeta("me", MetaSetPacket(
desc = MetaSetDesc(
public = TheCard(photo = base64Photo)
)
))
Result.success(Unit)
} catch (e: Exception) { Result.failure(e) }
val fileUrl = httpClient.uploadFile(imageUri, mimeType, fileName)
Result.success(fileUrl)
} catch (e: Exception) {
Result.failure(e)
}
}
// ─── Helpers ────────────────────────────────────────────────
@@ -34,6 +34,7 @@ class TinodeHttpClient(
val gson: Gson = GsonBuilder()
// НЕ включаем null поля — пустые объекты {} для sub/desc
.registerTypeAdapter(PubContent::class.java, PubContentDeserializer)
.registerTypeAdapter(TheCard::class.java, TheCardDeserializer)
.create()
private var webSocket: WebSocket? = null
private val _connectionEvents = MutableSharedFlow<ConnectionEvent>(extraBufferCapacity = 8)
@@ -296,7 +297,14 @@ class TinodeHttpClient(
}
suspend fun getData(topicName: String, since: Int = 0, limit: Int = 100): ServerMessage {
val msg = ClientMsgGet(get = GetPacket(id = generateId(), topic = topicName, data = MetaGetData(since = since, limit = limit)))
val msg = ClientMsgGet(
get = GetPacket(
id = generateId(),
topic = topicName,
data = MetaGetData(since = since, limit = limit),
what = "data"
)
)
return sendWithCallback(msg)
}
@@ -64,6 +64,9 @@ interface MessageDao {
@Query("SELECT MAX(seqId) FROM messages WHERE topicName = :topicName")
suspend fun getMaxSeq(topicName: String): Int?
@Query("SELECT * FROM messages WHERE topicName = :topicName ORDER BY timestamp DESC, seqId DESC LIMIT 1")
suspend fun getLatestMessage(topicName: String): MessageEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessages(messages: List<MessageEntity>)
@@ -88,6 +91,15 @@ interface MessageDao {
@Query("UPDATE messages SET attachmentUrl = :url, attachmentType = :type, hasAttachment = 1, timestamp = :now WHERE seqId = :seqId")
suspend fun updateAttachmentFull(seqId: Int, url: String, type: String, now: Long = System.currentTimeMillis())
@Query(
"DELETE FROM messages " +
"WHERE topicName = :topicName AND isOwn = 1 AND seqId < 0 AND (" +
"(:attachmentUrl IS NOT NULL AND attachmentUrl = :attachmentUrl) OR " +
"(:attachmentUrl IS NULL AND hasAttachment = 0 AND content = :content)" +
")"
)
suspend fun deleteOwnOptimisticDuplicate(topicName: String, content: String, attachmentUrl: String?)
/** Обновить URL вложения в последней собственной записи чата (для echo с сервера) */
@Query("UPDATE messages SET attachmentUrl = :url WHERE topicName = :topic AND isOwn = 1 AND hasAttachment = 1 AND seqId < 0")
suspend fun updateLastOwnAttachmentUrl(topic: String, url: String)
@@ -27,6 +27,41 @@ object PubContentDeserializer : JsonDeserializer<PubContent> {
}
}
// Кастомный десериализатор для TheCard.
// Сервер может слать public.photo как строку или объект (например {ref: "..."}).
object TheCardDeserializer : JsonDeserializer<TheCard> {
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): TheCard {
if (!json.isJsonObject) return TheCard()
val obj = json.asJsonObject
val fn = obj.get("fn")?.takeIf { it.isJsonPrimitive }?.asString
val photo = parsePhoto(obj.get("photo"))
return TheCard(fn = fn, photo = photo)
}
private fun parsePhoto(photoEl: JsonElement?): String? {
if (photoEl == null || photoEl.isJsonNull) return null
if (photoEl.isJsonPrimitive) {
return photoEl.asString
}
if (!photoEl.isJsonObject) return null
val obj = photoEl.asJsonObject
// Частые варианты в Tinode payload.
obj.get("ref")?.takeIf { it.isJsonPrimitive }?.let { return it.asString }
obj.get("val")?.takeIf { it.isJsonPrimitive }?.let { return it.asString }
obj.get("data")?.takeIf { it.isJsonObject }?.asJsonObject?.let { data ->
data.get("ref")?.takeIf { it.isJsonPrimitive }?.let { return it.asString }
data.get("val")?.takeIf { it.isJsonPrimitive }?.let { return it.asString }
}
return null
}
}
// ─── Client Messages ─────────────────────────────────────────────
data class ClientMsgHi(
@@ -117,7 +152,7 @@ data class MetaSetPacket(
data class MetaSetDesc(
val tags: List<String>? = null,
val `public`: TheCard? = null,
val `public`: Any? = null,
val `private`: Any? = null
)
@@ -303,12 +338,22 @@ data class MetaPacket(
val id: String? = null,
val topic: String? = null,
val desc: MetaDesc? = null,
val data: List<MetaDataPacket>? = null,
val sub: List<MetaSub>? = null,
val tags: List<String>? = null,
val cred: List<Any>? = null,
val `public`: TheCard? = null
)
data class MetaDataPacket(
val from: String? = null,
val seq: Int = 0,
val content: PubContent? = null,
val head: PubHead? = null,
val extra: PubExtra? = null,
val ts: String? = null
)
data class MetaDesc(
val created: String? = null,
val updated: String? = null,
@@ -1,21 +1,27 @@
package ru.lastochka.messenger.navigation
import android.net.Uri
/**
* Экраны приложения для Compose Navigation.
*/
sealed class Screen(val route: String) {
data object Chats : Screen("chats")
data object Chat : Screen("chat/{topicName}/{topicTitle}") {
fun createRoute(topicName: String, topicTitle: String) = "chat/$topicName/$topicTitle"
fun createRoute(topicName: String, topicTitle: String): String {
return "chat/${Uri.encode(topicName)}/${Uri.encode(topicTitle)}"
}
}
data object NewChat : Screen("new_chat")
data object Calls : Screen("calls")
data object Contacts : Screen("contacts")
data object Settings : Screen("settings")
data object Profile : Screen("profile")
data object ContactInfo : Screen("contact_info/{topicName}/{topicTitle}") {
fun createRoute(topicName: String, topicTitle: String) = "contact_info/$topicName/$topicTitle"
fun createRoute(topicName: String, topicTitle: String): String {
return "contact_info/${Uri.encode(topicName)}/${Uri.encode(topicTitle)}"
}
}
data object CreateGroup : Screen("create_group")
data object Login : Screen("login")
@@ -0,0 +1,123 @@
package ru.lastochka.messenger.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun ChatHeader(
name: String,
statusText: String?,
isOnline: Boolean,
isGroup: Boolean,
avatarUrl: String? = null,
onBack: () -> Unit,
onCall: () -> Unit,
onMore: () -> Unit,
onClick: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f),
shadowElevation = 3.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Назад")
}
Row(
modifier = Modifier
.weight(1f)
.clickable(enabled = onClick != null, onClick = onClick ?: {})
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
AvatarLarge(
name = name,
avatarUrl = avatarUrl,
isOnline = isOnline && !isGroup
)
Spacer(modifier = Modifier.size(10.dp))
Column {
Text(
text = name,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!statusText.isNullOrBlank()) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (isGroup) {
Icon(
imageVector = Icons.Default.Groups,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(12.dp)
)
}
Text(
text = statusText,
style = MaterialTheme.typography.bodySmall,
color = if (isOnline) Color(0xFF22C55E) else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
IconButton(onClick = onCall) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = "Позвонить",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = onMore) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Еще",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@@ -0,0 +1,230 @@
package ru.lastochka.messenger.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import ru.lastochka.messenger.data.ContactInfo
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
@Composable
fun ChatItem(
contact: ContactInfo,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val initials = remember(contact.displayName) {
contact.displayName
.split(" ")
.mapNotNull { it.firstOrNull()?.uppercase() }
.joinToString("")
.take(2)
}
val avatarColor = remember(contact.topicName) {
val source = contact.topicName.getOrElse(2) { 'a' }.code
Color.hsl((source * 37 % 360).toFloat(), 0.5f, 0.55f)
}
val timeString = remember(contact.timestamp) { formatTime(contact.timestamp) }
val isOwnLastMessage = (contact.lastMessage ?: "").startsWith("Вы:")
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(avatarColor),
contentAlignment = Alignment.Center
) {
if (!contact.avatar.isNullOrBlank()) {
AsyncImage(
model = contact.avatar,
contentDescription = contact.displayName,
modifier = Modifier
.size(48.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
} else {
Text(
text = initials,
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp
)
}
}
if (contact.isOnline && !contact.isGroup) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.size(12.dp)
.clip(CircleShape)
.background(Color(0xFF22C55E))
)
}
if (contact.isGroup) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.size(18.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Groups,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(11.dp)
)
}
}
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = contact.displayName,
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row(verticalAlignment = Alignment.CenterVertically) {
if (contact.pinned) {
Icon(
imageVector = Icons.Default.PushPin,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f),
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = timeString,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(2.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = contact.lastMessage ?: "",
style = MaterialTheme.typography.bodyMedium,
color = if (contact.lastMessage == "печатает...") {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Row(verticalAlignment = Alignment.CenterVertically) {
if (contact.muted) {
Icon(
imageVector = Icons.Default.NotificationsOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f),
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(4.dp))
}
if (contact.unread > 0) {
Box(
modifier = Modifier
.clip(CircleShape)
.background(if (contact.muted) Color(0xFF9CA3AF) else MaterialTheme.colorScheme.primary)
.padding(horizontal = 7.dp, vertical = 2.dp),
contentAlignment = Alignment.Center
) {
Text(
text = if (contact.unread > 99) "99+" else contact.unread.toString(),
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold
)
}
} else if (isOwnLastMessage) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(15.dp)
)
}
}
}
}
}
}
private fun formatTime(timestamp: java.util.Date?): String {
if (timestamp == null) return ""
val now = Calendar.getInstance()
val target = Calendar.getInstance().apply { time = timestamp }
val diffDays = ((now.timeInMillis - target.timeInMillis) / (24 * 60 * 60 * 1000)).toInt()
return when {
diffDays == 0 -> SimpleDateFormat("HH:mm", Locale("ru")).format(timestamp)
diffDays == 1 -> "Вчера"
diffDays < 7 -> SimpleDateFormat("EEE", Locale("ru")).format(timestamp)
else -> SimpleDateFormat("dd.MM.yy", Locale("ru")).format(timestamp)
}
}
@@ -0,0 +1,273 @@
package ru.lastochka.messenger.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
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 coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import ru.lastochka.messenger.LastochkaApp
import ru.lastochka.messenger.data.UiMessage
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import kotlin.math.roundToInt
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MessageBubble(
message: UiMessage,
modifier: Modifier = Modifier,
isFirstInGroup: Boolean = false,
isLastInGroup: Boolean = false,
showSender: Boolean = false,
onLongClick: (() -> Unit)? = null,
onSwipeReply: (() -> Unit)? = null,
onImageClick: ((String) -> Unit)? = null
) {
var offsetX by remember { mutableFloatStateOf(0f) }
val maxOffset = 92f
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale("ru")) }
val ownShape = when {
isLastInGroup -> RoundedCornerShape(18.dp, 18.dp, 18.dp, 6.dp)
else -> RoundedCornerShape(18.dp, 18.dp, 6.dp, 6.dp)
}
val peerShape = when {
isLastInGroup -> RoundedCornerShape(18.dp, 18.dp, 6.dp, 18.dp)
else -> RoundedCornerShape(18.dp, 18.dp, 6.dp, 6.dp)
}
val bubbleModifier = Modifier
.clip(if (message.isOwn) ownShape else peerShape)
.background(
if (message.isOwn) {
Brush.linearGradient(listOf(Color(0xFFEEF2FF), Color(0xFFE0E7FF)))
} else {
Brush.linearGradient(listOf(Color.White, Color(0xFFF8FAFC)))
}
)
.widthIn(max = 290.dp)
.padding(horizontal = 12.dp, vertical = 8.dp)
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = if (isFirstInGroup) 8.dp else 2.dp)
.offset { IntOffset(offsetX.roundToInt(), 0) }
.pointerInput(Unit) {
detectHorizontalDragGestures(
onHorizontalDrag = { _, dragAmount ->
if (dragAmount > 0) {
offsetX = (offsetX + dragAmount).coerceIn(0f, maxOffset)
}
},
onDragEnd = {
if (offsetX > maxOffset * 0.5f) onSwipeReply?.invoke()
offsetX = 0f
}
)
}
.combinedClickable(onClick = {}, onLongClick = onLongClick ?: {}),
horizontalAlignment = if (message.isOwn) Alignment.End else Alignment.Start
) {
if (showSender && !message.isOwn) {
Text(
text = message.senderName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 4.dp, bottom = 2.dp)
)
}
Box(modifier = bubbleModifier) {
Column {
if (message.replyToContent != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 6.dp)
.background(
MaterialTheme.colorScheme.primary.copy(alpha = 0.08f),
shape = RoundedCornerShape(8.dp)
)
.padding(horizontal = 8.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Box(
modifier = Modifier
.width(3.dp)
.height(30.dp)
.background(MaterialTheme.colorScheme.primary, RoundedCornerShape(2.dp))
)
Column {
Text("Ответ", fontSize = 11.sp, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold)
Text(
text = message.replyToContent,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
if (message.hasAttachment) {
MessageAttachmentImage(message = message, onImageClick = onImageClick)
if (message.content.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
}
}
if (message.content.isNotBlank() && message.content != " ") {
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF111827),
modifier = Modifier.padding(end = 46.dp)
)
}
Row(
modifier = Modifier
.align(Alignment.End)
.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
if (message.isEdited) {
Text("ред.", fontSize = 10.sp, color = Color(0xFF94A3B8))
}
Text(timeFormat.format(message.timestamp), fontSize = 11.sp, color = Color(0xFF94A3B8))
if (message.isOwn) {
Icon(
imageVector = if (message.isRead) Icons.Default.DoneAll else Icons.Default.Check,
contentDescription = null,
tint = if (message.isRead) MaterialTheme.colorScheme.primary else Color(0xFF94A3B8),
modifier = Modifier.size(14.dp)
)
}
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun MessageAttachmentImage(
message: UiMessage,
onImageClick: ((String) -> Unit)?
) {
val context = LocalContext.current
val imageData = if (message.attachmentUrl?.startsWith("data:") == true) {
message.attachmentUrl
} else {
message.attachmentUrl?.let {
(context.applicationContext as LastochkaApp).tinodeClient.buildFileDownloadUrl(it)
}
}
if (imageData == null) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(90.dp)
.clip(RoundedCornerShape(10.dp))
.background(Color(0xFFE5E7EB)),
contentAlignment = Alignment.Center
) {
Text("Отправка...", color = Color(0xFF6B7280), fontSize = 12.sp)
}
return
}
SubcomposeAsyncImage(
model = ImageRequest.Builder(context).data(imageData).crossfade(true).build(),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 260.dp)
.clip(RoundedCornerShape(12.dp))
.combinedClickable(
onClick = { onImageClick?.invoke(imageData) },
onLongClick = {}
),
contentScale = ContentScale.Fit,
loading = {
Box(modifier = Modifier.fillMaxWidth().height(120.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
}
}
)
}
@Composable
fun DateDivider(date: Date, modifier: Modifier = Modifier) {
val today = Calendar.getInstance()
val msgDay = Calendar.getInstance().apply { time = date }
val label = when {
today.get(Calendar.YEAR) == msgDay.get(Calendar.YEAR) &&
today.get(Calendar.DAY_OF_YEAR) == msgDay.get(Calendar.DAY_OF_YEAR) -> "Сегодня"
else -> SimpleDateFormat("dd MMMM yyyy", Locale("ru")).format(date)
}
Box(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Text(
text = label,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.72f))
.padding(horizontal = 10.dp, vertical = 5.dp)
)
}
}
@@ -0,0 +1,134 @@
package ru.lastochka.messenger.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddReaction
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
@Composable
fun MessageInput(
text: String,
onTextChanged: (String) -> Unit,
onSend: () -> Unit,
onMediaClick: () -> Unit,
onRichContentClick: () -> Unit,
modifier: Modifier = Modifier,
placeholder: String = "Сообщение...",
replyToMessage: ru.lastochka.messenger.data.UiMessage? = null,
richContentPickerActive: Boolean = false,
canSend: Boolean = text.trim().isNotEmpty()
) {
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
tonalElevation = 2.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = {
onRichContentClick()
},
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(
if (richContentPickerActive) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
} else {
Color.Transparent
}
)
) {
Icon(Icons.Default.AddReaction, contentDescription = "Эмодзи, стикеры, GIF")
}
IconButton(
onClick = onMediaClick,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(Color.Transparent)
) {
Icon(Icons.Default.Image, contentDescription = "Изображение")
}
Surface(
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f)
) {
BasicTextField(
value = text,
onValueChange = onTextChanged,
textStyle = TextStyle(
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.bodyLarge.fontSize
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 11.dp),
decorationBox = { inner ->
Box {
if (text.isEmpty()) {
Text(
text = placeholder,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
inner()
}
},
maxLines = 5
)
}
IconButton(
onClick = onSend,
enabled = canSend,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(
if (canSend) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceVariant
)
) {
Icon(
Icons.Default.Send,
contentDescription = "Отправить",
tint = if (canSend) Color.White else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@@ -22,8 +23,10 @@ import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.layout.ContentScale
@@ -57,7 +60,7 @@ fun ChatScreen(
viewModel: ChatViewModel = hiltViewModel()
) {
val messages by viewModel.messages.collectAsState()
val topicTitle by viewModel.topicTitle.collectAsState()
val vmTopicTitle by viewModel.topicTitle.collectAsState()
val isTyping by viewModel.isTyping.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val isSendingImage by viewModel.isSendingImage.collectAsState()
@@ -175,6 +178,10 @@ fun ChatScreen(
// BottomSheet для выбора источника медиа (галерея / камера)
var showMediaSourceSheet by remember { mutableStateOf(false) }
var showRichContentSheet by remember { mutableStateOf(false) }
var showEmojiSheet by remember { mutableStateOf(false) }
var showStickerSheet by remember { mutableStateOf(false) }
var showGifSheet by remember { mutableStateOf(false) }
LaunchedEffect(selectedMessage) {
if (selectedMessage != null) {
@@ -235,12 +242,77 @@ fun ChatScreen(
}
}
if (showEmojiSheet) {
ModalBottomSheet(
onDismissRequest = { showEmojiSheet = false },
sheetState = sheetState
) {
EmojiPickerSheet(
onSelect = { emoji ->
inputText += emoji
showEmojiSheet = false
}
)
}
}
if (showGifSheet) {
ModalBottomSheet(
onDismissRequest = { showGifSheet = false },
sheetState = sheetState
) {
GifPickerSheet(
onSelect = { gifUrl ->
inputText = listOf(inputText.trim(), gifUrl).filter { it.isNotBlank() }.joinToString(" ")
showGifSheet = false
}
)
}
}
if (showRichContentSheet) {
ModalBottomSheet(
onDismissRequest = { showRichContentSheet = false },
sheetState = sheetState
) {
Column(modifier = Modifier.padding(bottom = 24.dp)) {
SheetAction(Icons.Default.EmojiEmotions, "Эмодзи") {
showRichContentSheet = false
showEmojiSheet = true
}
SheetAction(Icons.Default.AddReaction, "Стикеры") {
showRichContentSheet = false
showStickerSheet = true
}
SheetAction(Icons.Default.GifBox, "GIF") {
showRichContentSheet = false
showGifSheet = true
}
}
}
}
if (showStickerSheet) {
ModalBottomSheet(
onDismissRequest = { showStickerSheet = false },
sheetState = sheetState
) {
StickerPickerSheet(
onSelect = { sticker ->
viewModel.sendSticker(sticker)
showStickerSheet = false
}
)
}
}
Scaffold(
topBar = {
ChatHeader(
name = topicTitle,
statusText = if (isTyping) "печатает" else "был(а) недавно",
name = vmTopicTitle.ifBlank { topicTitle.ifBlank { topicName } },
statusText = if (isTyping) "печатает..." else if (topicName.startsWith("grp")) "12 участников" else "был(а) недавно",
isOnline = false,
isGroup = topicName.startsWith("grp"),
onBack = onBack,
onCall = {},
onMore = {},
@@ -287,10 +359,15 @@ fun ChatScreen(
inputText = ""
}
},
onAttach = {
onMediaClick = {
showMediaSourceSheet = true
},
replyToMessage = replyToMessage
onRichContentClick = {
showRichContentSheet = true
},
replyToMessage = replyToMessage,
richContentPickerActive = showRichContentSheet || showEmojiSheet || showStickerSheet || showGifSheet,
canSend = selectedImageUri != null || inputText.isNotBlank()
)
}
},
@@ -312,10 +389,19 @@ fun ChatScreen(
}
}
) { padding ->
val darkBg = MaterialTheme.colorScheme.background.luminance() < 0.4f
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFE5DDD5)) // Telegram-like background color
.background(
brush = Brush.linearGradient(
if (darkBg) {
listOf(Color(0xFF0A0F18), Color(0xFF0E1621), Color(0xFF111B27))
} else {
listOf(Color(0xFFF8F9FC), Color(0xFFF0F2F7), Color(0xFFE8ECF3))
}
)
)
.padding(padding)
) {
if (isLoading) {
@@ -694,6 +780,140 @@ private suspend fun PointerInputScope.detectPinchZoom(
}
}
@Composable
private fun EmojiPickerSheet(onSelect: (String) -> Unit) {
val emojis = listOf(
"😀", "😁", "😂", "🤣", "😊", "😍", "😘", "😎",
"🤔", "😢", "😭", "😡", "👍", "👎", "🙏", "👏",
"🔥", "❤️", "💙", "💚", "🎉", "🤝", "🤗", "👌"
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Выберите эмодзи",
style = MaterialTheme.typography.titleMedium
)
emojis.chunked(8).forEach { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
row.forEach { emoji ->
Surface(
modifier = Modifier
.weight(1f)
.clickable { onSelect(emoji) },
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Box(
modifier = Modifier.padding(vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Text(text = emoji, fontSize = 24.sp)
}
}
}
repeat(8 - row.size) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
@Composable
private fun StickerPickerSheet(onSelect: (String) -> Unit) {
val stickers = listOf("😄", "😂", "🤣", "🥳", "🤩", "😎", "🔥", "❤️", "👍", "👏", "🎉", "💯")
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Выберите стикер",
style = MaterialTheme.typography.titleMedium
)
stickers.chunked(6).forEach { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
row.forEach { sticker ->
Surface(
modifier = Modifier
.weight(1f)
.clickable { onSelect(sticker) },
shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f)
) {
Box(
modifier = Modifier.padding(vertical = 14.dp),
contentAlignment = Alignment.Center
) {
Text(text = sticker, fontSize = 28.sp)
}
}
}
repeat(6 - row.size) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
@Composable
private fun GifPickerSheet(onSelect: (String) -> Unit) {
val gifs = listOf(
"https://media.giphy.com/media/ICOgUNjpvO0PC/giphy.gif",
"https://media.giphy.com/media/3o7aD2saalBwwftBIY/giphy.gif",
"https://media.giphy.com/media/l0HlQ7LRalQqdWfao/giphy.gif",
"https://media.giphy.com/media/xT0xeJpnrWC4XWblEk/giphy.gif"
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "Выберите GIF",
style = MaterialTheme.typography.titleMedium
)
gifs.forEach { gifUrl ->
Surface(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.clickable { onSelect(gifUrl) },
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f)
) {
AsyncImage(
model = gifUrl,
contentDescription = "GIF",
modifier = Modifier
.fillMaxWidth()
.height(140.dp),
contentScale = ContentScale.Crop
)
}
}
}
}
// ─── Camera helpers ─────────────────────────────────────────────
private fun doLaunchCamera(
@@ -0,0 +1,319 @@
package ru.lastochka.messenger.ui.screens.chatlist
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Snackbar
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import ru.lastochka.messenger.R
import ru.lastochka.messenger.ui.components.ChatItem
import ru.lastochka.messenger.viewmodel.ChatListViewModel
@Composable
fun ChatListScreen(
onChatClick: (String, String) -> Unit,
onNewChat: () -> Unit,
onCreateGroup: () -> Unit,
darkMode: Boolean,
onToggleDarkMode: () -> Unit,
onLogout: () -> Unit,
viewModel: ChatListViewModel = hiltViewModel()
) {
val contacts by viewModel.filteredContacts.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val error by viewModel.error.collectAsState()
val totalUnread by viewModel.totalUnread.collectAsState()
val pinned = remember(contacts) { contacts.filter { it.pinned } }
val unpinned = remember(contacts) { contacts.filterNot { it.pinned } }
var showMenu by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.reloadContacts()
}
LaunchedEffect(error) {
if (error == "SESSION_EXPIRED") {
viewModel.clearError()
onLogout()
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(
brush = if (darkMode) {
Brush.linearGradient(
listOf(Color(0xFF0A0F18), Color(0xFF0E1621), Color(0xFF111B27))
)
} else {
Brush.linearGradient(
listOf(Color(0xFFF8F9FC), Color(0xFFF0F2F7), Color(0xFFE8ECF3))
)
}
)
) {
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 12.dp, top = 18.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Image(
painter = painterResource(id = R.drawable.logo_splash),
contentDescription = "Логотип",
modifier = Modifier
.height(44.dp)
.padding(top = 2.dp, bottom = 2.dp),
contentScale = ContentScale.Fit
)
Text(
text = "$totalUnread непрочитанных",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Box {
IconButton(onClick = { showMenu = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "Меню")
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text(if (darkMode) "Светлый режим" else "Ночной режим") },
leadingIcon = {
Icon(
imageVector = if (darkMode) Icons.Default.LightMode else Icons.Default.DarkMode,
contentDescription = null
)
},
onClick = {
showMenu = false
onToggleDarkMode()
}
)
DropdownMenuItem(
text = { Text("Создать группу") },
onClick = {
showMenu = false
onCreateGroup()
}
)
DropdownMenuItem(
text = { Text("Новый чат") },
onClick = {
showMenu = false
onNewChat()
}
)
DropdownMenuItem(
text = { Text("Выйти") },
onClick = {
showMenu = false
onLogout()
}
)
}
}
}
OutlinedTextField(
value = searchQuery,
onValueChange = viewModel::onSearchQueryChanged,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = { Text("Поиск...") },
singleLine = true,
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
shape = RoundedCornerShape(16.dp),
colors = OutlinedTextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f),
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.35f)
)
)
Box(modifier = Modifier.fillMaxSize()) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = MaterialTheme.colorScheme.primary
)
} else if (contacts.isEmpty()) {
EmptyChatsState(searchQuery = searchQuery)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = 6.dp, bottom = 88.dp)
) {
if (pinned.isNotEmpty()) {
item {
Text(
text = "Закрепленные",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 2.dp)
)
}
items(pinned, key = { it.topicName }) { contact ->
ChatItem(
contact = contact,
onClick = { onChatClick(contact.topicName, contact.displayName) }
)
}
}
if (unpinned.isNotEmpty()) {
item {
Text(
text = if (pinned.isEmpty()) "Все чаты" else "Остальные",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 2.dp)
)
}
items(unpinned, key = { it.topicName }) { contact ->
ChatItem(
contact = contact,
onClick = { onChatClick(contact.topicName, contact.displayName) }
)
}
}
}
}
}
}
FloatingActionButton(
onClick = onNewChat,
containerColor = MaterialTheme.colorScheme.primary,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp)
) {
Icon(Icons.Default.Add, contentDescription = "Новый чат", tint = Color.White)
}
if (error != null) {
Snackbar(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(horizontal = 16.dp, vertical = 24.dp),
containerColor = MaterialTheme.colorScheme.error,
contentColor = Color.White
) {
Text(error ?: "")
}
}
}
}
@Composable
private fun HeaderCircleButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
onClick: () -> Unit,
label: String
) {
IconButton(
onClick = onClick,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f))
) {
Icon(
imageVector = icon,
contentDescription = label,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun EmptyChatsState(searchQuery: String) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = 72.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(76.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Send,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(30.dp)
)
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = if (searchQuery.isBlank()) "Нет чатов" else "Ничего не найдено",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@@ -0,0 +1,129 @@
package ru.lastochka.messenger.ui.screens.contacts
import android.Manifest
import android.content.pm.PackageManager
import android.provider.ContactsContract
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContactPhone
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
private data class PhoneContact(
val name: String,
val phone: String
)
@Composable
fun ContactsScreen() {
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
PackageManager.PERMISSION_GRANTED
)
}
var contacts by remember { mutableStateOf(emptyList<PhoneContact>()) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
hasPermission = granted
}
LaunchedEffect(hasPermission) {
if (!hasPermission) return@LaunchedEffect
val loaded = mutableListOf<PhoneContact>()
val seen = HashSet<String>()
val projection = arrayOf(
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
ContactsContract.CommonDataKinds.Phone.NUMBER
)
context.contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
projection,
null,
null,
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " ASC"
)?.use { cursor ->
val nameIdx = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)
val phoneIdx = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
while (cursor.moveToNext()) {
val name = cursor.getString(nameIdx).orEmpty().ifBlank { "Без имени" }
val phone = cursor.getString(phoneIdx).orEmpty()
val key = "$name|$phone"
if (phone.isNotBlank() && seen.add(key)) {
loaded.add(PhoneContact(name = name, phone = phone))
}
}
}
contacts = loaded
}
if (!hasPermission) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(24.dp)
) {
Icon(Icons.Default.ContactPhone, contentDescription = null)
Text("Нужен доступ к контактам", style = MaterialTheme.typography.titleMedium)
Button(onClick = { permissionLauncher.launch(Manifest.permission.READ_CONTACTS) }) {
Text("Разрешить доступ")
}
}
}
return
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(contacts) { item ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(Icons.Default.Person, contentDescription = null)
Column {
Text(item.name, fontWeight = FontWeight.SemiBold)
Text(item.phone, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
@@ -48,7 +48,7 @@ fun NewChatScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Новый чат") },
title = { Text("Добавить чат") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, "Назад")
@@ -62,31 +62,6 @@ fun NewChatScreen(
.fillMaxSize()
.padding(padding)
) {
// Actions Row
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = onCreateGroup,
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.Group, null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Группа")
}
Button(
onClick = onCreateChannel,
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.VolumeUp, null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Канал")
}
}
// Search Header
Text(
text = "Искать пользователей",
@@ -253,4 +228,4 @@ fun SearchUserItem(
}
}
}
}
}
@@ -0,0 +1,319 @@
package ru.lastochka.messenger.ui.screens.settings
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
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.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import kotlinx.coroutines.launch
import ru.lastochka.messenger.viewmodel.ProfileViewModel
@androidx.compose.material3.ExperimentalMaterial3Api
@Composable
fun ProfileScreen(
onNavigateBack: () -> Unit,
viewModel: ProfileViewModel = hiltViewModel()
) {
val uid by viewModel.uid.collectAsState()
val name by viewModel.name.collectAsState()
val bio by viewModel.bio.collectAsState()
val avatar by viewModel.avatar.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val error by viewModel.error.collectAsState()
val saved by viewModel.saved.collectAsState()
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
var selectedAvatarUri by remember { mutableStateOf<Uri?>(null) }
var removeAvatar by remember { mutableStateOf(false) }
val avatarPicker = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) {
selectedAvatarUri = uri
removeAvatar = false
}
}
LaunchedEffect(error) {
if (!error.isNullOrBlank()) {
snackbarHostState.showSnackbar(error ?: "")
viewModel.clearError()
}
}
LaunchedEffect(saved) {
if (saved) {
viewModel.consumeSavedFlag()
onNavigateBack()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Профиль", fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onNavigateBack, enabled = !isLoading) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад")
}
},
actions = {
FilledTonalButton(
onClick = {
scope.launch { viewModel.saveProfile(selectedAvatarUri, removeAvatar) }
},
enabled = !isLoading && name.isNotBlank()
) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp))
}
Spacer(modifier = Modifier.size(6.dp))
Text("Сохранить")
}
}
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(
brush = Brush.linearGradient(
listOf(
MaterialTheme.colorScheme.primary.copy(alpha = 0.22f),
MaterialTheme.colorScheme.tertiary.copy(alpha = 0.18f),
MaterialTheme.colorScheme.surface
)
)
)
.padding(20.dp)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
Box(contentAlignment = Alignment.BottomEnd) {
Surface(
modifier = Modifier
.size(104.dp)
.clip(CircleShape)
.border(
width = 3.dp,
color = MaterialTheme.colorScheme.surface,
shape = CircleShape
),
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer
) {
val avatarModel: Any? = when {
removeAvatar -> null
selectedAvatarUri != null -> selectedAvatarUri
else -> avatar
}
Crossfade(targetState = avatarModel, label = "avatarCrossfade") { model ->
if (model != null) {
AsyncImage(
model = model,
contentDescription = "Аватар",
modifier = Modifier.fillMaxSize()
)
} else {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
Text(
text = name.take(1).uppercase(),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.Bold
)
}
}
}
}
Surface(
modifier = Modifier
.size(34.dp)
.clip(CircleShape),
shape = CircleShape,
color = MaterialTheme.colorScheme.primary
) {
IconButton(
onClick = {
avatarPicker.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
}
) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = "Изменить аватар",
tint = Color.White,
modifier = Modifier.size(16.dp)
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = name.ifBlank { "Без имени" },
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = if (uid.isBlank()) "uid: —" else "uid: $uid",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(6.dp))
TextButton(
onClick = {
avatarPicker.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
}
) {
Text("Выбрать фото профиля")
}
if (avatar != null || selectedAvatarUri != null) {
TextButton(
onClick = {
selectedAvatarUri = null
removeAvatar = true
}
) {
Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.size(6.dp))
Text("Удалить аватар")
}
}
}
}
}
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedTextField(
value = name,
onValueChange = viewModel::updateName,
label = { Text("Имя") },
placeholder = { Text("Ваше отображаемое имя") },
leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words)
)
OutlinedTextField(
value = bio,
onValueChange = viewModel::updateBio,
label = { Text("О себе") },
placeholder = { Text("Короткое описание профиля") },
leadingIcon = { Icon(Icons.Default.Info, contentDescription = null) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 4,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences)
)
}
}
Text(
text = "Изменения синхронизируются с сервером и видны вашим контактам.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp)
)
if (isLoading) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
}
}
}
}
}
@@ -0,0 +1,272 @@
package ru.lastochka.messenger.ui.screens.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun SettingsScreen(
onNavigateToProfile: () -> Unit,
onBack: () -> Unit,
darkMode: Boolean,
onToggleDarkMode: () -> Unit,
onLogout: () -> Unit
) {
var notifications by remember { mutableStateOf(true) }
var chatNotifications by remember { mutableStateOf(true) }
var vibration by remember { mutableStateOf(true) }
Column(
modifier = Modifier
.fillMaxSize()
.background(
brush = if (darkMode) {
Brush.linearGradient(listOf(Color(0xFF0A0F18), Color(0xFF0E1621), Color(0xFF111B27)))
} else {
Brush.linearGradient(listOf(Color(0xFFF8F9FC), Color(0xFFF0F2F7), Color(0xFFE8ECF3)))
}
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp, end = 16.dp, top = 16.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.clickable(onClick = onBack),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.ArrowBack, contentDescription = "Назад")
}
Text(
text = "Настройки",
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
SettingsSection(title = "Аккаунт") {
SettingsItem(Icons.Default.Person, "Мой профиль", "Имя, фото, статус", onClick = onNavigateToProfile)
SettingsDivider()
SettingsItem(Icons.Default.Key, "Конфиденциальность", "Кто видит мой статус")
SettingsDivider()
SettingsItem(Icons.Default.Lock, "Безопасность", "Двухфакторная аутентификация")
}
}
item {
SettingsSection(title = "Уведомления") {
SettingsToggle(Icons.Default.Notifications, "Уведомления", "Push и звук", notifications) { notifications = it }
SettingsDivider()
SettingsToggle(Icons.Default.Notifications, "Уведомления в чатах", "Сообщения от контактов", chatNotifications) { chatNotifications = it }
SettingsDivider()
SettingsToggle(Icons.Default.Notifications, "Вибрация", null, vibration) { vibration = it }
}
}
item {
SettingsSection(title = "Оформление") {
SettingsToggle(
if (darkMode) Icons.Default.LightMode else Icons.Default.DarkMode,
"Тёмная тема",
null,
darkMode
) { onToggleDarkMode() }
SettingsDivider()
SettingsItem(Icons.Default.Palette, "Цвет акцента", "Индиго")
SettingsDivider()
SettingsItem(Icons.Default.Language, "Язык", "Русский")
}
}
item {
SettingsSection(title = "Данные и память") {
SettingsItem(Icons.Default.Storage, "Использование памяти", "2.4 ГБ из 5 ГБ")
}
}
item {
SettingsSection(title = "О приложении") {
SettingsItem(Icons.Default.Info, "Версия", "Ласточка 1.0.0 (прототип)")
}
}
item {
Button(
onClick = onLogout,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(52.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFEF4444))
) {
Icon(Icons.Default.Logout, contentDescription = null, tint = Color.White)
Spacer(modifier = Modifier.size(8.dp))
Text("Выйти", color = Color.White, fontWeight = FontWeight.SemiBold)
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
@Composable
private fun SettingsSection(
title: String?,
content: @Composable ColumnScope.() -> Unit
) {
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
if (!title.isNullOrBlank()) {
Text(
text = title.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp, bottom = 6.dp)
)
}
Card(
shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.78f)
)
) {
Column(content = content)
}
}
}
@Composable
private fun SettingsItem(
icon: ImageVector,
label: String,
description: String?,
onClick: (() -> Unit)? = null
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = onClick != null, onClick = onClick ?: {})
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)),
contentAlignment = Alignment.Center
) {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
}
Spacer(modifier = Modifier.size(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(label, fontSize = 15.sp, fontWeight = FontWeight.Medium)
if (!description.isNullOrBlank()) {
Text(description, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@Composable
private fun SettingsToggle(
icon: ImageVector,
label: String,
description: String?,
checked: Boolean,
onChange: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)),
contentAlignment = Alignment.Center
) {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
}
Spacer(modifier = Modifier.size(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(label, fontSize = 15.sp, fontWeight = FontWeight.Medium)
if (!description.isNullOrBlank()) {
Text(description, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Switch(checked = checked, onCheckedChange = onChange)
}
}
@Composable
private fun SettingsDivider() {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp)
.height(1.dp)
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.28f))
)
}
@@ -89,9 +89,9 @@ class ChatListViewModel @Inject constructor(
}
}
private fun refreshContacts() {
private suspend fun refreshContacts() {
val contacts = repository.getContactsFromSubs(subs)
_contacts.value = contacts
_contacts.value = repository.enrichContactsWithLastMessages(contacts)
}
private fun listenForUpdates() {
@@ -120,10 +120,20 @@ class ChatListViewModel @Inject constructor(
}
fun refresh() {
refreshContacts()
viewModelScope.launch {
refreshContacts()
}
}
fun clearError() {
_error.value = null
}
/**
* Явная перезагрузка списка чатов.
* Нужна после логина/восстановления сессии, чтобы не зависеть только от init-блока.
*/
fun reloadContacts() {
loadContacts()
}
}
@@ -69,9 +69,9 @@ class ChatViewModel @Inject constructor(
val editingMessage: StateFlow<UiMessage?> = _editingMessage.asStateFlow()
init {
listenForMessages()
loadTopic()
loadMessages()
listenForMessages()
}
private fun loadTopic() {
@@ -84,7 +84,12 @@ class ChatViewModel @Inject constructor(
viewModelScope.launch {
_isLoading.value = true
try {
repository.subscribeTopic(topicName)
repository.subscribeTopic(topicName).onFailure { e ->
_error.value = e.message ?: "Не удалось подписаться на чат"
}
// Веб-клиент после subscribe сразу подгружает историю.
// Делаем то же самое явно, чтобы не зависеть от варианта ответа сервера.
repository.loadMessagesBefore(topicName, Int.MAX_VALUE, 200)
database.messageDao().getMessagesForTopic(topicName).collect { entities ->
_messages.value = entities.map { entity ->
@@ -118,20 +123,31 @@ class ChatViewModel @Inject constructor(
is TinodeEvent.NewMessage -> {
val data = event.data
if (data.topic == topicName) {
// Эхо своего сообщения — обновляем локальную запись с tempSeqId на реальный seqId
val myUid = repository.myUid
if (data.from == myUid) {
// Эхо своего сообщения — просто пропускаем.
// Для текстовых сообщений: Room уже получил его из listenForMessages → saveMessageFromServer.
// Для изображений: URL уже сохранён в upload-функции через updateAttachmentFull(tempSeqId, ...).
// updateLastOwnAttachmentUrl УДАЛЁН — он обновлял ВСЕ сообщения с seqId < 0,
// заменяя разные картинки одной и той же (баг #2).
return@collect
}
// Не игнорируем собственные сообщения от сервера:
// при первом открытии чата это единственный источник истории.
saveMessageFromServer(data)
repository.markAsRead(topicName, data.seq)
}
}
is TinodeEvent.Meta -> {
val meta = event.data
if (meta.topic == topicName && !meta.data.isNullOrEmpty()) {
meta.data.forEach { item ->
if (item.seq > 0) {
val packet = ru.lastochka.messenger.data.model.DataPacket(
topic = topicName,
from = item.from,
seq = item.seq,
content = item.content ?: ru.lastochka.messenger.data.model.PubContent(""),
head = item.head,
extra = item.extra,
ts = item.ts
)
saveMessageFromServer(packet)
}
}
}
}
is TinodeEvent.Info -> {
val info = event.data
if (info.topic == topicName && info.what == "kp") {
@@ -211,6 +227,13 @@ class ChatViewModel @Inject constructor(
attachmentType = attachmentType,
attachmentUrl = attachmentUrl
)
if (data.from == repository.myUid) {
database.messageDao().deleteOwnOptimisticDuplicate(
topicName = topicName,
content = entity.content,
attachmentUrl = attachmentUrl
)
}
database.messageDao().insertMessage(entity)
}
@@ -347,6 +370,11 @@ class ChatViewModel @Inject constructor(
}
}
fun sendSticker(sticker: String) {
if (sticker.isBlank()) return
sendMessage(sticker)
}
// State for image sending
private val _isSendingImage = MutableStateFlow(false)
val isSendingImage: StateFlow<Boolean> = _isSendingImage.asStateFlow()
@@ -0,0 +1,122 @@
package ru.lastochka.messenger.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import ru.lastochka.messenger.data.ChatRepository
import ru.lastochka.messenger.data.ContactInfo
import ru.lastochka.messenger.data.local.ContactEntity
import java.util.Date
import javax.inject.Inject
/**
* ViewModel для поиска пользователей и создания нового чата.
*/
@HiltViewModel
class NewChatViewModel @Inject constructor(
private val repository: ChatRepository
) : ViewModel() {
private val _searchResults = MutableStateFlow<List<ContactInfo>>(emptyList())
val searchResults: StateFlow<List<ContactInfo>> = _searchResults.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private var searchJob: Job? = null
private fun normalizeText(value: String?): String = value?.trim()?.lowercase() ?: ""
private fun normalizeDigits(value: String?): String = value?.replace("[^\\d]".toRegex(), "") ?: ""
private fun contactEntityToInfo(entity: ContactEntity): ContactInfo = ContactInfo(
topicName = entity.topicName,
displayName = entity.displayName,
avatar = entity.avatar,
lastMessage = entity.lastMessage,
timestamp = Date(entity.lastMessageTime),
unread = entity.unread,
isGroup = entity.isGroup,
muted = entity.muted,
pinned = entity.pinned
)
private fun localMatch(entity: ContactEntity, query: String): Boolean {
val q = normalizeText(query)
if (q.isBlank()) return true
val qDigits = normalizeDigits(q)
val name = normalizeText(entity.displayName)
val topic = normalizeText(entity.topicName)
return name.contains(q) || topic.contains(q) || (qDigits.isNotBlank() && normalizeDigits(entity.topicName).contains(qDigits))
}
/**
* Поиск пользователей по запросу с debounce.
*/
fun search(query: String) {
searchJob?.cancel()
if (query.length < 2) {
_searchResults.value = emptyList()
return
}
searchJob = viewModelScope.launch {
delay(400) // Debounce
_isLoading.value = true
try {
val localFromDb = repository.getContacts().first()
.filter { localMatch(it, query) }
.map { contactEntityToInfo(it) }
val localFromMeTopic = runCatching {
val subs = repository.getMeTopic()
repository.getContactsFromSubs(subs).filter {
localMatch(
ContactEntity(
topicName = it.topicName,
displayName = it.displayName,
avatar = it.avatar,
lastMessage = it.lastMessage,
lastMessageTime = it.timestamp?.time ?: 0L,
unread = it.unread,
isGroup = it.isGroup,
muted = it.muted,
pinned = it.pinned
),
query
)
}
}.getOrDefault(emptyList())
val remoteResults = repository.searchUsers(query)
val merged = LinkedHashMap<String, ContactInfo>()
localFromDb.forEach { merged[it.topicName] = it }
localFromMeTopic.forEach { merged.putIfAbsent(it.topicName, it) }
remoteResults.forEach { merged.putIfAbsent(it.topicName, it) }
_searchResults.value = merged.values.toList()
} catch (e: CancellationException) {
// Нормально при debounce: не затираем результаты отменённым job.
throw e
} catch (e: Exception) {
_searchResults.value = emptyList()
} finally {
_isLoading.value = false
}
}
}
/**
* Начать чат с выбранным пользователем.
*/
suspend fun startChat(topicName: String): Result<Unit> {
return repository.startChatWithUser(topicName)
}
fun clearResults() {
_searchResults.value = emptyList()
searchJob?.cancel()
}
}
@@ -0,0 +1,154 @@
package ru.lastochka.messenger.viewmodel
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.lastochka.messenger.data.ChatRepository
import java.io.File
import java.io.FileOutputStream
import javax.inject.Inject
/**
* ViewModel для экрана профиля.
*/
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val repository: ChatRepository,
@ApplicationContext private val context: Context
) : ViewModel() {
private val _uid = MutableStateFlow("")
val uid: StateFlow<String> = _uid.asStateFlow()
private val _name = MutableStateFlow("")
val name: StateFlow<String> = _name.asStateFlow()
private val _bio = MutableStateFlow("")
val bio: StateFlow<String> = _bio.asStateFlow()
private val _avatar = MutableStateFlow<String?>(null)
val avatar: StateFlow<String?> = _avatar.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
private val _saved = MutableStateFlow(false)
val saved: StateFlow<Boolean> = _saved.asStateFlow()
init {
loadProfile()
}
private fun loadProfile() {
viewModelScope.launch {
try {
val result = repository.getMyProfile()
if (result.isSuccess) {
val profile = result.getOrNull()
_uid.value = profile?.uid.orEmpty()
_name.value = profile?.displayName ?: ""
_bio.value = profile?.bio ?: ""
_avatar.value = profile?.avatar
} else {
_uid.value = repository.myUid.orEmpty()
}
} catch (e: Exception) {
_uid.value = repository.myUid.orEmpty()
}
}
}
fun updateName(newName: String) {
_name.value = newName
}
fun updateBio(newBio: String) {
_bio.value = newBio
}
suspend fun saveProfile(newAvatarUri: Uri?, removeAvatar: Boolean) {
_isLoading.value = true
_error.value = null
_saved.value = false
try {
var photoUrl = if (removeAvatar) null else _avatar.value
if (!removeAvatar && newAvatarUri != null) {
val prepared = prepareAvatarUpload(newAvatarUri)
val fileName = "avatar_${System.currentTimeMillis()}.jpg"
val avatarResult = repository.updateAvatar(
imageUri = prepared,
mimeType = "image/jpeg",
fileName = fileName
)
if (avatarResult.isSuccess) {
photoUrl = avatarResult.getOrNull()
_avatar.value = photoUrl
} else {
_error.value = avatarResult.exceptionOrNull()?.message ?: "Не удалось загрузить аватар"
return
}
}
val result = repository.updateProfile(
name = name.value.trim(),
bio = bio.value.trim(),
photoUrl = photoUrl
)
if (result.isFailure) {
_error.value = result.exceptionOrNull()?.message ?: "Не удалось сохранить профиль"
} else {
_saved.value = true
}
} finally {
_isLoading.value = false
}
}
fun consumeSavedFlag() {
_saved.value = false
}
fun clearError() {
_error.value = null
}
private suspend fun prepareAvatarUpload(sourceUri: Uri): Uri = withContext(Dispatchers.IO) {
val input = context.contentResolver.openInputStream(sourceUri)
?: throw IllegalStateException("Не удалось открыть изображение")
val original = input.use { BitmapFactory.decodeStream(it) }
?: throw IllegalStateException("Не удалось декодировать изображение")
val side = minOf(original.width, original.height)
val left = (original.width - side) / 2
val top = (original.height - side) / 2
val cropped = Bitmap.createBitmap(original, left, top, side, side)
if (cropped !== original) original.recycle()
val size = 768
val scaled = if (cropped.width != size || cropped.height != size) {
Bitmap.createScaledBitmap(cropped, size, size, true).also {
if (it !== cropped) cropped.recycle()
}
} else {
cropped
}
val file = File(context.cacheDir, "avatar_upload_${System.currentTimeMillis()}.jpg")
FileOutputStream(file).use { out ->
scaled.compress(Bitmap.CompressFormat.JPEG, 88, out)
}
scaled.recycle()
Uri.fromFile(file)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

@@ -5,5 +5,5 @@
круглой/квадратной маской adaptive icon.
-->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@mipmap/ic_launcher_foreground"
android:drawable="@drawable/logo_splash"
android:inset="16%" />

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Some files were not shown because too many files have changed in this diff Show More