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>
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
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)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
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: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |