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
|
# Temporary
|
||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.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_IMAGES" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".LastochkaApp"
|
android:name=".LastochkaApp"
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
package ru.lastochka.messenger
|
package ru.lastochka.messenger
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.delay
|
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.material.icons.automirrored.filled.Reply
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
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.navigation.Screen
|
||||||
import ru.lastochka.messenger.ui.screens.auth.LoginScreen
|
import ru.lastochka.messenger.ui.screens.auth.LoginScreen
|
||||||
import ru.lastochka.messenger.ui.screens.auth.RegisterScreen
|
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.chat.ChatScreen
|
||||||
import ru.lastochka.messenger.ui.screens.contact.ContactInfoScreen
|
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.groups.CreateGroupScreen
|
||||||
import ru.lastochka.messenger.ui.screens.chatlist.ChatListScreen
|
import ru.lastochka.messenger.ui.screens.chatlist.ChatListScreen
|
||||||
import ru.lastochka.messenger.ui.screens.newchat.NewChatScreen
|
import ru.lastochka.messenger.ui.screens.newchat.NewChatScreen
|
||||||
@@ -65,7 +68,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
splashScreen.setKeepOnScreenCondition { keepSplashOnScreen }
|
splashScreen.setKeepOnScreenCondition { keepSplashOnScreen }
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
LastochkaTheme {
|
val systemDark = isSystemInDarkTheme()
|
||||||
|
var darkMode by rememberSaveable { mutableStateOf(systemDark) }
|
||||||
|
|
||||||
|
LastochkaTheme(darkTheme = darkMode) {
|
||||||
// AuthState как StateFlow — реагирует на logout/login
|
// AuthState как StateFlow — реагирует на logout/login
|
||||||
val authState by sessionRepository.authState.collectAsState()
|
val authState by sessionRepository.authState.collectAsState()
|
||||||
val isAuthenticated = authState is AuthState.Authenticated
|
val isAuthenticated = authState is AuthState.Authenticated
|
||||||
@@ -93,6 +99,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
MainAppScreen(
|
MainAppScreen(
|
||||||
|
darkMode = darkMode,
|
||||||
|
onToggleDarkMode = { darkMode = !darkMode },
|
||||||
onLogoutRequired = {
|
onLogoutRequired = {
|
||||||
// Logout через SessionRepository
|
// Logout через SessionRepository
|
||||||
kotlinx.coroutines.GlobalScope.launch {
|
kotlinx.coroutines.GlobalScope.launch {
|
||||||
@@ -141,7 +149,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainAppScreen(onLogoutRequired: () -> Unit) {
|
fun MainAppScreen(
|
||||||
|
darkMode: Boolean,
|
||||||
|
onToggleDarkMode: () -> Unit,
|
||||||
|
onLogoutRequired: () -> Unit
|
||||||
|
) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
var selectedTab by remember { mutableStateOf(0) }
|
var selectedTab by remember { mutableStateOf(0) }
|
||||||
val chatListViewModel: ChatListViewModel = hiltViewModel()
|
val chatListViewModel: ChatListViewModel = hiltViewModel()
|
||||||
@@ -154,8 +166,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
LaunchedEffect(currentRoute) {
|
LaunchedEffect(currentRoute) {
|
||||||
selectedTab = when {
|
selectedTab = when {
|
||||||
currentRoute?.startsWith(Screen.Chats.route) == true -> 0
|
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.Settings.route) == true -> 2
|
||||||
|
currentRoute?.startsWith(Screen.Profile.route) == true -> 3
|
||||||
else -> selectedTab
|
else -> selectedTab
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,13 +204,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
selected = selectedTab == 1,
|
selected = selectedTab == 1,
|
||||||
onClick = {
|
onClick = {
|
||||||
selectedTab = 1
|
selectedTab = 1
|
||||||
navController.navigate(Screen.Calls.route) {
|
navController.navigate(Screen.Contacts.route) {
|
||||||
popUpTo(Screen.Calls.route) { inclusive = false }
|
popUpTo(Screen.Contacts.route) { inclusive = false }
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon = { Icon(Icons.Default.Call, null) },
|
icon = { Icon(Icons.Default.Contacts, null) },
|
||||||
label = { Text("Звонки", fontWeight = if (selectedTab == 1) FontWeight.Bold else FontWeight.Normal) }
|
label = { Text("Контакты", fontWeight = if (selectedTab == 1) FontWeight.Bold else FontWeight.Normal) }
|
||||||
)
|
)
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
selected = selectedTab == 2,
|
selected = selectedTab == 2,
|
||||||
@@ -211,6 +224,18 @@ class MainActivity : ComponentActivity() {
|
|||||||
icon = { Icon(Icons.Default.Settings, null) },
|
icon = { Icon(Icons.Default.Settings, null) },
|
||||||
label = { Text("Настройки", fontWeight = if (selectedTab == 2) FontWeight.Bold else FontWeight.Normal) }
|
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 ->
|
) { padding ->
|
||||||
@@ -228,9 +253,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
onNewChat = {
|
onNewChat = {
|
||||||
navController.navigate(Screen.NewChat.route)
|
navController.navigate(Screen.NewChat.route)
|
||||||
},
|
},
|
||||||
onProfile = {
|
onCreateGroup = {
|
||||||
navController.navigate(Screen.Profile.route)
|
navController.navigate(Screen.CreateGroup.route)
|
||||||
},
|
},
|
||||||
|
darkMode = darkMode,
|
||||||
|
onToggleDarkMode = onToggleDarkMode,
|
||||||
onLogout = onLogoutRequired
|
onLogout = onLogoutRequired
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -244,8 +271,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val topicName = backStackEntry.arguments?.getString("topicName") ?: ""
|
val topicName = Uri.decode(backStackEntry.arguments?.getString("topicName") ?: "")
|
||||||
val topicTitleArg = backStackEntry.arguments?.getString("topicTitle") ?: ""
|
val topicTitleArg = Uri.decode(backStackEntry.arguments?.getString("topicTitle") ?: "")
|
||||||
// Если title пустой или равен topicName — загружаем настоящее имя из контакта
|
// Если title пустой или равен topicName — загружаем настоящее имя из контакта
|
||||||
val effectiveTitle = if (topicTitleArg.isBlank() || topicTitleArg == topicName) {
|
val effectiveTitle = if (topicTitleArg.isBlank() || topicTitleArg == topicName) {
|
||||||
null // Загрузим из сервера
|
null // Загрузим из сервера
|
||||||
@@ -268,8 +295,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
navArgument("topicTitle") { type = NavType.StringType }
|
navArgument("topicTitle") { type = NavType.StringType }
|
||||||
)
|
)
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val topicName = backStackEntry.arguments?.getString("topicName") ?: ""
|
val topicName = Uri.decode(backStackEntry.arguments?.getString("topicName") ?: "")
|
||||||
val topicTitle = backStackEntry.arguments?.getString("topicTitle") ?: ""
|
val topicTitle = Uri.decode(backStackEntry.arguments?.getString("topicTitle") ?: "")
|
||||||
ContactInfoScreen(
|
ContactInfoScreen(
|
||||||
topicName = topicName,
|
topicName = topicName,
|
||||||
topicTitle = topicTitle,
|
topicTitle = topicTitle,
|
||||||
@@ -308,9 +335,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Calls Graph ---
|
// --- Contacts Graph ---
|
||||||
composable(Screen.Calls.route) {
|
composable(Screen.Contacts.route) {
|
||||||
CallsScreen()
|
ContactsScreen()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Settings Graph ---
|
// --- Settings Graph ---
|
||||||
@@ -319,6 +346,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
onNavigateToProfile = {
|
onNavigateToProfile = {
|
||||||
navController.navigate(Screen.Profile.route)
|
navController.navigate(Screen.Profile.route)
|
||||||
},
|
},
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
darkMode = darkMode,
|
||||||
|
onToggleDarkMode = onToggleDarkMode,
|
||||||
onLogout = onLogoutRequired
|
onLogout = onLogoutRequired
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import ru.lastochka.messenger.data.local.AppDatabase
|
import ru.lastochka.messenger.data.local.AppDatabase
|
||||||
import ru.lastochka.messenger.data.local.ContactEntity
|
import ru.lastochka.messenger.data.local.ContactEntity
|
||||||
import ru.lastochka.messenger.data.local.MessageEntity
|
import ru.lastochka.messenger.data.local.MessageEntity
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repository — единая точка доступа к данным (Tinode + Room).
|
* Repository — единая точка доступа к данным (Tinode + Room).
|
||||||
@@ -101,6 +102,22 @@ class ChatRepository(
|
|||||||
return tinodeClient.getContacts(subs)
|
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 {
|
suspend fun getTopicTitle(topicName: String): String {
|
||||||
return tinodeClient.getTopicTitle(topicName)
|
return tinodeClient.getTopicTitle(topicName)
|
||||||
}
|
}
|
||||||
@@ -162,8 +179,16 @@ class ChatRepository(
|
|||||||
tinodeClient.editMessage(topicName, seqId, newText)
|
tinodeClient.editMessage(topicName, seqId, newText)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateProfile(name: String, bio: String): Result<Unit> {
|
suspend fun updateProfile(name: String, bio: String, photoUrl: String?): Result<Unit> {
|
||||||
return tinodeClient.updateProfile(name, bio)
|
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> {
|
suspend fun getMyProfile(): Result<ru.lastochka.messenger.data.UserProfile> {
|
||||||
@@ -175,4 +200,20 @@ class ChatRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val events = tinodeClient.events
|
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
|
// Deferred для me-topic subscriptions
|
||||||
private var meSubsDeferred: CompletableDeferred<List<MetaSub>>? = null
|
private var meSubsDeferred: CompletableDeferred<List<MetaSub>>? = null
|
||||||
private val meSubsMutex = Mutex()
|
private val meSubsMutex = Mutex()
|
||||||
|
private var meDescDeferred: CompletableDeferred<MetaDesc>? = null
|
||||||
|
private val meDescMutex = Mutex()
|
||||||
private val connectMutex = Mutex()
|
private val connectMutex = Mutex()
|
||||||
|
|
||||||
// Deferreds для получения участников топика (группы)
|
// Deferreds для получения участников топика (группы)
|
||||||
@@ -107,6 +109,13 @@ class TinodeClient(
|
|||||||
meSubsDeferred = null
|
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 ->
|
meta.topic?.let { t ->
|
||||||
val memberDeferred = topicMemberDeferreds[t]
|
val memberDeferred = topicMemberDeferreds[t]
|
||||||
@@ -490,25 +499,54 @@ class TinodeClient(
|
|||||||
// Флаг: true — ожидаем результаты после setMeta, false — игнорируем meta от подписки
|
// Флаг: true — ожидаем результаты после setMeta, false — игнорируем meta от подписки
|
||||||
@Volatile private var fndResultsRequested = false
|
@Volatile private var fndResultsRequested = false
|
||||||
|
|
||||||
|
private fun normalizePhoneDigits(value: String): String = value.replace("[^\\d]".toRegex(), "")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Определить тег поиска FND по запросу.
|
* Построить список FND-запросов как в web-prototype:
|
||||||
* Телефон (начинается с +7/8/7 и 10-11 цифр) → "tel:79991234567"
|
* - оригинал/normal/basic
|
||||||
* Иначе → "basic:<query>"
|
* - fn:<token>, name:<token>
|
||||||
|
* - tel:* для телефонных вариантов
|
||||||
*/
|
*/
|
||||||
private fun buildFndTag(query: String): String {
|
private fun buildFndQueries(rawQuery: String): List<String> {
|
||||||
val digits = query.replace("[^\\d]".toRegex(), "")
|
val trimmed = rawQuery.trim()
|
||||||
val looksLikePhone = digits.length >= 10 &&
|
if (trimmed.isBlank()) return emptyList()
|
||||||
(query.trimStart().startsWith("+") || query.trimStart().first().isDigit())
|
|
||||||
if (looksLikePhone) {
|
val base = if (trimmed.startsWith("@")) trimmed.drop(1) else trimmed
|
||||||
val normalized = when {
|
val normalized = base.lowercase(Locale.ROOT)
|
||||||
digits.startsWith("8") && digits.length == 11 -> "7" + digits.drop(1)
|
val phoneDigits = normalizePhoneDigits(base)
|
||||||
digits.startsWith("7") && digits.length == 11 -> digits
|
val tokens = normalized.split("\\s+".toRegex()).map { it.trim() }.filter { it.isNotBlank() }
|
||||||
digits.length == 10 -> "7$digits"
|
|
||||||
else -> digits
|
val queries = linkedSetOf<String>()
|
||||||
|
queries += trimmed
|
||||||
|
queries += normalized
|
||||||
|
|
||||||
|
if (normalized.isNotBlank() && !normalized.contains(":") && tokens.size == 1) {
|
||||||
|
queries += "basic:$normalized"
|
||||||
}
|
}
|
||||||
return "tel:$normalized"
|
|
||||||
|
if (!normalized.contains(":")) {
|
||||||
|
tokens.forEach { token ->
|
||||||
|
queries += "fn:$token"
|
||||||
|
queries += "name:$token"
|
||||||
}
|
}
|
||||||
return "basic:$query"
|
}
|
||||||
|
|
||||||
|
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> {
|
suspend fun searchUsers(query: String): List<ContactInfo> {
|
||||||
if (!awaitConnection()) return emptyList()
|
if (!awaitConnection()) return emptyList()
|
||||||
if (query.length < 2) return emptyList()
|
val normalizedQuery = query.trim()
|
||||||
|
if (normalizedQuery.length < 2) return emptyList()
|
||||||
|
|
||||||
return fndMutex.withLock {
|
return fndMutex.withLock {
|
||||||
fndResultsRequested = false
|
fndResultsRequested = false
|
||||||
try {
|
try {
|
||||||
val deferred = CompletableDeferred<List<ContactInfo>>()
|
|
||||||
fndDeferred = deferred
|
|
||||||
|
|
||||||
// Подписываемся на fnd topic (или переподписываемся, если уже подписаны)
|
// Подписываемся на fnd topic (или переподписываемся, если уже подписаны)
|
||||||
val r = httpClient.subscribe("fnd", MetaGetPacket(
|
val r = httpClient.subscribe("fnd", MetaGetPacket(
|
||||||
desc = MetaGetDesc(),
|
desc = MetaGetDesc(),
|
||||||
sub = MetaGetSub()
|
sub = MetaGetSub()
|
||||||
))
|
))
|
||||||
if (r.ctrl?.code !in 200..299) {
|
// 304 "already subscribed" для fnd — штатный ответ.
|
||||||
fndDeferred = null
|
if (r.ctrl?.code !in 200..399) {
|
||||||
return@withLock emptyList<ContactInfo>()
|
return@withLock emptyList<ContactInfo>()
|
||||||
}
|
}
|
||||||
|
|
||||||
val tag = buildFndTag(query)
|
val allResults = linkedMapOf<String, ContactInfo>()
|
||||||
Timber.d("searchUsers: query='$query', tag='$tag'")
|
val fndQueries = buildFndQueries(normalizedQuery)
|
||||||
|
Timber.d("searchUsers: query='$normalizedQuery', fndQueries=${fndQueries.size}")
|
||||||
|
|
||||||
// Устанавливаем поисковый запрос. После этого сервер пришлёт META с результатами.
|
fndQueries.forEach { tag ->
|
||||||
|
suspend fun runOneQuery(set: MetaSetPacket) {
|
||||||
|
val deferred = CompletableDeferred<List<ContactInfo>>()
|
||||||
|
fndDeferred = deferred
|
||||||
fndResultsRequested = true
|
fndResultsRequested = true
|
||||||
httpClient.setMeta("fnd", MetaSetPacket(
|
try {
|
||||||
desc = MetaSetDesc(tags = listOf(tag))
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ждём результат с таймаутом
|
// Режим как в web-prototype.
|
||||||
withTimeout(6000) { deferred.await() }
|
runOneQuery(MetaSetPacket(desc = MetaSetDesc(public = tag)))
|
||||||
|
// Fallback для серверов, где поиск живёт в desc.tags.
|
||||||
|
runOneQuery(MetaSetPacket(desc = MetaSetDesc(tags = listOf(tag))))
|
||||||
|
}
|
||||||
|
|
||||||
|
allResults.values.toList()
|
||||||
} catch (e: TimeoutCancellationException) {
|
} catch (e: TimeoutCancellationException) {
|
||||||
Timber.w("searchUsers: timeout for query='$query'")
|
Timber.w("searchUsers: timeout for query='$normalizedQuery'")
|
||||||
fndDeferred = null
|
fndDeferred = null
|
||||||
emptyList()
|
emptyList()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
// Debounce в ViewModel отменяет предыдущие поисковые jobs — это нормально.
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "searchUsers: error")
|
Timber.e(e, "searchUsers: error")
|
||||||
fndDeferred = null
|
fndDeferred = null
|
||||||
@@ -599,11 +659,27 @@ class TinodeClient(
|
|||||||
suspend fun loadMessagesBefore(topicName: String, beforeSeq: Int, limit: Int): List<DataPacket> {
|
suspend fun loadMessagesBefore(topicName: String, beforeSeq: Int, limit: Int): List<DataPacket> {
|
||||||
if (!awaitConnection()) return emptyList()
|
if (!awaitConnection()) return emptyList()
|
||||||
return try {
|
return try {
|
||||||
// Запрашиваем данные у сервера
|
// beforeSeq в текущем протоколе клиента не используется для "до seq":
|
||||||
|
// запрашиваем последние сообщения через since=0 и limit.
|
||||||
val response = httpClient.getData(topicName, since = 0, limit = limit)
|
val response = httpClient.getData(topicName, since = 0, limit = limit)
|
||||||
// Tinode возвращает meta с данными, но для getData ответ — это meta
|
// Часть серверов может вернуть data внутри meta.data в том же ответе.
|
||||||
// Сообщения приходят через DATA события в event flow
|
response.meta?.data?.forEach { item ->
|
||||||
// Поэтому просто возвращаем пустой список — сообщения добавятся через listenForMessages
|
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()
|
emptyList()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
emptyList()
|
emptyList()
|
||||||
@@ -621,8 +697,13 @@ class TinodeClient(
|
|||||||
sub = MetaGetSub(),
|
sub = MetaGetSub(),
|
||||||
data = MetaGetData(0, 50)
|
data = MetaGetData(0, 50)
|
||||||
))
|
))
|
||||||
if (r.ctrl?.code in 200..299) Result.success(Unit)
|
if (r.ctrl?.code in 200..299) {
|
||||||
else Result.failure(Exception(r.ctrl?.text ?: "Subscribe failed"))
|
// Явно запрашиваем историю: часть серверов не присылает её через 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) }
|
} catch (e: Exception) { Result.failure(e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -932,13 +1013,16 @@ class TinodeClient(
|
|||||||
* Обновить профиль пользователя (имя + bio).
|
* Обновить профиль пользователя (имя + bio).
|
||||||
* Передаёт displayName в fn и bio в note через me-топик.
|
* Передаёт 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("Нет подключения к серверу"))
|
if (!awaitConnection()) return Result.failure(Exception("Нет подключения к серверу"))
|
||||||
return try {
|
return try {
|
||||||
httpClient.subscribe("me", MetaGetPacket(desc = MetaGetDesc()))
|
httpClient.subscribe("me", MetaGetPacket(desc = MetaGetDesc()))
|
||||||
httpClient.setMeta("me", MetaSetPacket(
|
httpClient.setMeta("me", MetaSetPacket(
|
||||||
desc = MetaSetDesc(
|
desc = MetaSetDesc(
|
||||||
public = TheCard(fn = displayName, photo = null),
|
public = TheCard(
|
||||||
|
fn = displayName.trim().ifBlank { null },
|
||||||
|
photo = photoUrl
|
||||||
|
),
|
||||||
private = PrivateData(note = bio)
|
private = PrivateData(note = bio)
|
||||||
)
|
)
|
||||||
))
|
))
|
||||||
@@ -966,26 +1050,30 @@ class TinodeClient(
|
|||||||
suspend fun getMyProfile(): Result<UserProfile> {
|
suspend fun getMyProfile(): Result<UserProfile> {
|
||||||
if (!awaitConnection()) return Result.failure(Exception("Нет подключения к серверу"))
|
if (!awaitConnection()) return Result.failure(Exception("Нет подключения к серверу"))
|
||||||
return try {
|
return try {
|
||||||
val r = httpClient.subscribe("me", MetaGetPacket(
|
val deferred = CompletableDeferred<MetaDesc>()
|
||||||
desc = MetaGetDesc(),
|
meDescMutex.withLock {
|
||||||
sub = null
|
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 = desc?.`public`?.fn ?: ""
|
||||||
val displayName = r.meta?.desc?.`public`?.fn
|
val bio = (desc?.`private` as? Map<*, *>)?.get("note") as? String
|
||||||
?: r.meta?.`public`?.fn
|
val avatar = desc?.`public`?.photo
|
||||||
?: ""
|
|
||||||
|
|
||||||
// Bio хранится в meta.desc.private.note
|
Result.success(
|
||||||
val bio = (r.meta?.desc?.`private` as? Map<*, *>)?.get("note") as? String
|
UserProfile(
|
||||||
|
|
||||||
Result.success(UserProfile(
|
|
||||||
uid = myUid ?: "",
|
uid = myUid ?: "",
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
avatar = r.meta?.desc?.`public`?.photo
|
avatar = avatar,
|
||||||
?: r.meta?.`public`?.photo,
|
|
||||||
bio = bio
|
bio = bio
|
||||||
))
|
)
|
||||||
|
)
|
||||||
} catch (e: Exception) { Result.failure(e) }
|
} catch (e: Exception) { Result.failure(e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -993,16 +1081,14 @@ class TinodeClient(
|
|||||||
* Обновить аватар пользователя.
|
* Обновить аватар пользователя.
|
||||||
* Отправляет base64-изображение через setMeta me-топика.
|
* Отправляет 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("Нет подключения к серверу"))
|
if (!awaitConnection()) return Result.failure(Exception("Нет подключения к серверу"))
|
||||||
return try {
|
return try {
|
||||||
httpClient.setMeta("me", MetaSetPacket(
|
val fileUrl = httpClient.uploadFile(imageUri, mimeType, fileName)
|
||||||
desc = MetaSetDesc(
|
Result.success(fileUrl)
|
||||||
public = TheCard(photo = base64Photo)
|
} catch (e: Exception) {
|
||||||
)
|
Result.failure(e)
|
||||||
))
|
}
|
||||||
Result.success(Unit)
|
|
||||||
} catch (e: Exception) { Result.failure(e) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────
|
||||||
@@ -34,6 +34,7 @@ class TinodeHttpClient(
|
|||||||
val gson: Gson = GsonBuilder()
|
val gson: Gson = GsonBuilder()
|
||||||
// НЕ включаем null поля — пустые объекты {} для sub/desc
|
// НЕ включаем null поля — пустые объекты {} для sub/desc
|
||||||
.registerTypeAdapter(PubContent::class.java, PubContentDeserializer)
|
.registerTypeAdapter(PubContent::class.java, PubContentDeserializer)
|
||||||
|
.registerTypeAdapter(TheCard::class.java, TheCardDeserializer)
|
||||||
.create()
|
.create()
|
||||||
private var webSocket: WebSocket? = null
|
private var webSocket: WebSocket? = null
|
||||||
private val _connectionEvents = MutableSharedFlow<ConnectionEvent>(extraBufferCapacity = 8)
|
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 {
|
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)
|
return sendWithCallback(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +64,9 @@ interface MessageDao {
|
|||||||
@Query("SELECT MAX(seqId) FROM messages WHERE topicName = :topicName")
|
@Query("SELECT MAX(seqId) FROM messages WHERE topicName = :topicName")
|
||||||
suspend fun getMaxSeq(topicName: String): Int?
|
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)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insertMessages(messages: List<MessageEntity>)
|
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")
|
@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())
|
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 с сервера) */
|
/** Обновить URL вложения в последней собственной записи чата (для echo с сервера) */
|
||||||
@Query("UPDATE messages SET attachmentUrl = :url WHERE topicName = :topic AND isOwn = 1 AND hasAttachment = 1 AND seqId < 0")
|
@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)
|
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 ─────────────────────────────────────────────
|
// ─── Client Messages ─────────────────────────────────────────────
|
||||||
|
|
||||||
data class ClientMsgHi(
|
data class ClientMsgHi(
|
||||||
@@ -117,7 +152,7 @@ data class MetaSetPacket(
|
|||||||
|
|
||||||
data class MetaSetDesc(
|
data class MetaSetDesc(
|
||||||
val tags: List<String>? = null,
|
val tags: List<String>? = null,
|
||||||
val `public`: TheCard? = null,
|
val `public`: Any? = null,
|
||||||
val `private`: Any? = null
|
val `private`: Any? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -303,12 +338,22 @@ data class MetaPacket(
|
|||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
val topic: String? = null,
|
val topic: String? = null,
|
||||||
val desc: MetaDesc? = null,
|
val desc: MetaDesc? = null,
|
||||||
|
val data: List<MetaDataPacket>? = null,
|
||||||
val sub: List<MetaSub>? = null,
|
val sub: List<MetaSub>? = null,
|
||||||
val tags: List<String>? = null,
|
val tags: List<String>? = null,
|
||||||
val cred: List<Any>? = null,
|
val cred: List<Any>? = null,
|
||||||
val `public`: TheCard? = 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(
|
data class MetaDesc(
|
||||||
val created: String? = null,
|
val created: String? = null,
|
||||||
val updated: String? = null,
|
val updated: String? = null,
|
||||||
@@ -1,21 +1,27 @@
|
|||||||
package ru.lastochka.messenger.navigation
|
package ru.lastochka.messenger.navigation
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Экраны приложения для Compose Navigation.
|
* Экраны приложения для Compose Navigation.
|
||||||
*/
|
*/
|
||||||
sealed class Screen(val route: String) {
|
sealed class Screen(val route: String) {
|
||||||
data object Chats : Screen("chats")
|
data object Chats : Screen("chats")
|
||||||
data object Chat : Screen("chat/{topicName}/{topicTitle}") {
|
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 NewChat : Screen("new_chat")
|
||||||
|
|
||||||
data object Calls : Screen("calls")
|
data object Contacts : Screen("contacts")
|
||||||
|
|
||||||
data object Settings : Screen("settings")
|
data object Settings : Screen("settings")
|
||||||
data object Profile : Screen("profile")
|
data object Profile : Screen("profile")
|
||||||
data object ContactInfo : Screen("contact_info/{topicName}/{topicTitle}") {
|
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 CreateGroup : Screen("create_group")
|
||||||
data object Login : Screen("login")
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
@@ -22,8 +23,10 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.*
|
import androidx.compose.ui.*
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
import androidx.compose.ui.input.pointer.*
|
import androidx.compose.ui.input.pointer.*
|
||||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
@@ -57,7 +60,7 @@ fun ChatScreen(
|
|||||||
viewModel: ChatViewModel = hiltViewModel()
|
viewModel: ChatViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val messages by viewModel.messages.collectAsState()
|
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 isTyping by viewModel.isTyping.collectAsState()
|
||||||
val isLoading by viewModel.isLoading.collectAsState()
|
val isLoading by viewModel.isLoading.collectAsState()
|
||||||
val isSendingImage by viewModel.isSendingImage.collectAsState()
|
val isSendingImage by viewModel.isSendingImage.collectAsState()
|
||||||
@@ -175,6 +178,10 @@ fun ChatScreen(
|
|||||||
|
|
||||||
// BottomSheet для выбора источника медиа (галерея / камера)
|
// BottomSheet для выбора источника медиа (галерея / камера)
|
||||||
var showMediaSourceSheet by remember { mutableStateOf(false) }
|
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) {
|
LaunchedEffect(selectedMessage) {
|
||||||
if (selectedMessage != null) {
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
ChatHeader(
|
ChatHeader(
|
||||||
name = topicTitle,
|
name = vmTopicTitle.ifBlank { topicTitle.ifBlank { topicName } },
|
||||||
statusText = if (isTyping) "печатает…" else "был(а) недавно",
|
statusText = if (isTyping) "печатает..." else if (topicName.startsWith("grp")) "12 участников" else "был(а) недавно",
|
||||||
isOnline = false,
|
isOnline = false,
|
||||||
|
isGroup = topicName.startsWith("grp"),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onCall = {},
|
onCall = {},
|
||||||
onMore = {},
|
onMore = {},
|
||||||
@@ -287,10 +359,15 @@ fun ChatScreen(
|
|||||||
inputText = ""
|
inputText = ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onAttach = {
|
onMediaClick = {
|
||||||
showMediaSourceSheet = true
|
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 ->
|
) { padding ->
|
||||||
|
val darkBg = MaterialTheme.colorScheme.background.luminance() < 0.4f
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.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)
|
.padding(padding)
|
||||||
) {
|
) {
|
||||||
if (isLoading) {
|
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 ─────────────────────────────────────────────
|
// ─── Camera helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
private fun doLaunchCamera(
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Новый чат") },
|
title = { Text("Добавить чат") },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.Default.ArrowBack, "Назад")
|
Icon(Icons.Default.ArrowBack, "Назад")
|
||||||
@@ -62,31 +62,6 @@ fun NewChatScreen(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.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
|
// Search Header
|
||||||
Text(
|
Text(
|
||||||
text = "Искать пользователей",
|
text = "Искать пользователей",
|
||||||
@@ -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)
|
val contacts = repository.getContactsFromSubs(subs)
|
||||||
_contacts.value = contacts
|
_contacts.value = repository.enrichContactsWithLastMessages(contacts)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun listenForUpdates() {
|
private fun listenForUpdates() {
|
||||||
@@ -120,10 +120,20 @@ class ChatListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
|
viewModelScope.launch {
|
||||||
refreshContacts()
|
refreshContacts()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun clearError() {
|
fun clearError() {
|
||||||
_error.value = null
|
_error.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Явная перезагрузка списка чатов.
|
||||||
|
* Нужна после логина/восстановления сессии, чтобы не зависеть только от init-блока.
|
||||||
|
*/
|
||||||
|
fun reloadContacts() {
|
||||||
|
loadContacts()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -69,9 +69,9 @@ class ChatViewModel @Inject constructor(
|
|||||||
val editingMessage: StateFlow<UiMessage?> = _editingMessage.asStateFlow()
|
val editingMessage: StateFlow<UiMessage?> = _editingMessage.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
listenForMessages()
|
||||||
loadTopic()
|
loadTopic()
|
||||||
loadMessages()
|
loadMessages()
|
||||||
listenForMessages()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadTopic() {
|
private fun loadTopic() {
|
||||||
@@ -84,7 +84,12 @@ class ChatViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isLoading.value = true
|
_isLoading.value = true
|
||||||
try {
|
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 ->
|
database.messageDao().getMessagesForTopic(topicName).collect { entities ->
|
||||||
_messages.value = entities.map { entity ->
|
_messages.value = entities.map { entity ->
|
||||||
@@ -118,20 +123,31 @@ class ChatViewModel @Inject constructor(
|
|||||||
is TinodeEvent.NewMessage -> {
|
is TinodeEvent.NewMessage -> {
|
||||||
val data = event.data
|
val data = event.data
|
||||||
if (data.topic == topicName) {
|
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)
|
saveMessageFromServer(data)
|
||||||
repository.markAsRead(topicName, data.seq)
|
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 -> {
|
is TinodeEvent.Info -> {
|
||||||
val info = event.data
|
val info = event.data
|
||||||
if (info.topic == topicName && info.what == "kp") {
|
if (info.topic == topicName && info.what == "kp") {
|
||||||
@@ -211,6 +227,13 @@ class ChatViewModel @Inject constructor(
|
|||||||
attachmentType = attachmentType,
|
attachmentType = attachmentType,
|
||||||
attachmentUrl = attachmentUrl
|
attachmentUrl = attachmentUrl
|
||||||
)
|
)
|
||||||
|
if (data.from == repository.myUid) {
|
||||||
|
database.messageDao().deleteOwnOptimisticDuplicate(
|
||||||
|
topicName = topicName,
|
||||||
|
content = entity.content,
|
||||||
|
attachmentUrl = attachmentUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
database.messageDao().insertMessage(entity)
|
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
|
// State for image sending
|
||||||
private val _isSendingImage = MutableStateFlow(false)
|
private val _isSendingImage = MutableStateFlow(false)
|
||||||
val isSendingImage: StateFlow<Boolean> = _isSendingImage.asStateFlow()
|
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.
|
круглой/квадратной маской adaptive icon.
|
||||||
-->
|
-->
|
||||||
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:drawable="@mipmap/ic_launcher_foreground"
|
android:drawable="@drawable/logo_splash"
|
||||||
android:inset="16%" />
|
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 |