[feat/fix]:
- хз чет пофиксил
This commit is contained in:
parent
4f3df5e440
commit
105b0d5bad
@ -10,7 +10,7 @@ import retrofit2.http.Part
|
|||||||
interface StorageApi {
|
interface StorageApi {
|
||||||
|
|
||||||
@Multipart
|
@Multipart
|
||||||
@POST("v1/storage/upload/avatar")
|
@POST("v1/storage/avatar/upload")
|
||||||
suspend fun uploadAvatar(
|
suspend fun uploadAvatar(
|
||||||
@Part file: MultipartBody.Part
|
@Part file: MultipartBody.Part
|
||||||
): Response<UploadAvatarResponseDto>
|
): Response<UploadAvatarResponseDto>
|
||||||
|
|||||||
@ -1,27 +1,14 @@
|
|||||||
package org.yobble.messenger.presentation.chat
|
package org.yobble.messenger.presentation.chat
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
|
||||||
import androidx.compose.material.icons.filled.Delete
|
|
||||||
import androidx.compose.material.icons.filled.Edit
|
|
||||||
import androidx.compose.material.icons.filled.AttachFile
|
|
||||||
import androidx.compose.material.icons.filled.Keyboard
|
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
import androidx.compose.material.icons.outlined.EmojiEmotions
|
|
||||||
import androidx.compose.material.icons.filled.Verified
|
import androidx.compose.material.icons.filled.Verified
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.emoji2.emojipicker.EmojiPickerView
|
import androidx.emoji2.emojipicker.EmojiPickerView
|
||||||
@ -29,15 +16,9 @@ import androidx.compose.material3.*
|
|||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@ -46,9 +27,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.yobble.messenger.data.remote.dto.MessageItemDto
|
import org.yobble.messenger.data.remote.dto.MessageItemDto
|
||||||
|
import org.yobble.messenger.presentation.chat.components.DeleteMessageDialog
|
||||||
|
import org.yobble.messenger.presentation.chat.components.EditMessageDialog
|
||||||
|
import org.yobble.messenger.presentation.chat.components.MessageActionsSheet
|
||||||
|
import org.yobble.messenger.presentation.chat.components.MessageBubble
|
||||||
|
import org.yobble.messenger.presentation.chat.components.MessageInputBar
|
||||||
import org.yobble.messenger.presentation.common.UserAvatar
|
import org.yobble.messenger.presentation.common.UserAvatar
|
||||||
import org.yobble.messenger.util.formatLastSeen
|
import org.yobble.messenger.util.formatLastSeen
|
||||||
import org.yobble.messenger.util.formatUtcToLocalTime
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@ -303,204 +288,3 @@ private fun ChatTopBar(uiState: ChatUiState, onNavigateBack: () -> Unit, onNavig
|
|||||||
}
|
}
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region Dialogs
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun MessageActionsSheet(msg: MessageItemDto?, currentUserId: String, clipboard: androidx.compose.ui.platform.ClipboardManager, onDismiss: () -> Unit, onEdit: (MessageItemDto) -> Unit, onDelete: (MessageItemDto) -> Unit) {
|
|
||||||
if (msg == null) return
|
|
||||||
val isOwn = msg.senderId == currentUserId
|
|
||||||
ModalBottomSheet(onDismissRequest = onDismiss, containerColor = MaterialTheme.colorScheme.surfaceVariant) {
|
|
||||||
Column(Modifier.padding(bottom = 32.dp)) {
|
|
||||||
if (!msg.content.isNullOrBlank()) {
|
|
||||||
ActionRow(Icons.Default.ContentCopy, "Copy") { clipboard.setText(AnnotatedString(msg.content)); onDismiss() }
|
|
||||||
}
|
|
||||||
if (isOwn && !msg.content.isNullOrBlank()) {
|
|
||||||
ActionRow(Icons.Default.Edit, "Edit") { onEdit(msg) }
|
|
||||||
}
|
|
||||||
ActionRow(Icons.Default.Delete, "Delete", MaterialTheme.colorScheme.error) { onDelete(msg) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DeleteMessageDialog(msg: MessageItemDto?, currentUserId: String, deleteForAll: Boolean, onDeleteForAllChange: (Boolean) -> Unit, onConfirm: (MessageItemDto) -> Unit, onDismiss: () -> Unit) {
|
|
||||||
if (msg == null) return
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text("Delete message?") },
|
|
||||||
text = {
|
|
||||||
if (msg.senderId == currentUserId) {
|
|
||||||
Row(Modifier.fillMaxWidth().clickable { onDeleteForAllChange(!deleteForAll) }, verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Checkbox(deleteForAll, onDeleteForAllChange)
|
|
||||||
Spacer(Modifier.width(4.dp))
|
|
||||||
Text("Delete for everyone")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = { TextButton(onClick = { onConfirm(msg) }) { Text("Delete", color = MaterialTheme.colorScheme.error) } },
|
|
||||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun EditMessageDialog(msg: MessageItemDto?, text: String, onTextChange: (String) -> Unit, onConfirm: (MessageItemDto) -> Unit, onDismiss: () -> Unit) {
|
|
||||||
if (msg == null) return
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text("Edit message") },
|
|
||||||
text = {
|
|
||||||
TextField(text, onTextChange, Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp),
|
|
||||||
colors = TextFieldDefaults.colors(focusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedContainerColor = MaterialTheme.colorScheme.surface, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent))
|
|
||||||
},
|
|
||||||
confirmButton = { TextButton(onClick = { onConfirm(msg) }, enabled = text.isNotBlank()) { Text("Save") } },
|
|
||||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ActionRow(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, color: Color = MaterialTheme.colorScheme.onSurface, onClick: () -> Unit) {
|
|
||||||
Row(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 24.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Icon(icon, null, tint = color, modifier = Modifier.size(22.dp))
|
|
||||||
Spacer(Modifier.width(16.dp))
|
|
||||||
Text(label, style = MaterialTheme.typography.bodyLarge, color = color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// endregion
|
|
||||||
|
|
||||||
// region MessageBubble
|
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
|
||||||
private fun MessageBubble(message: MessageItemDto, isOutgoing: Boolean, onLongClick: () -> Unit = {}) {
|
|
||||||
val primary = MaterialTheme.colorScheme.primary
|
|
||||||
val primaryContainer = MaterialTheme.colorScheme.primaryContainer
|
|
||||||
val textColor = if (isOutgoing) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
|
|
||||||
val timeColor = if (isOutgoing) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
val shape = if (isOutgoing) RoundedCornerShape(18.dp, 18.dp, 4.dp, 18.dp) else RoundedCornerShape(18.dp, 18.dp, 18.dp, 4.dp)
|
|
||||||
val time = formatUtcToLocalTime(message.createdAt)
|
|
||||||
|
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start) {
|
|
||||||
Box(
|
|
||||||
Modifier.widthIn(max = 280.dp).clip(shape)
|
|
||||||
.combinedClickable(onClick = {}, onLongClick = onLongClick)
|
|
||||||
.then(if (isOutgoing) Modifier.background(Brush.linearGradient(listOf(primary, primaryContainer))) else Modifier.background(MaterialTheme.colorScheme.surfaceVariant))
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
if (!message.content.isNullOrBlank()) Text(message.content, color = textColor, fontSize = 15.sp)
|
|
||||||
Spacer(Modifier.height(2.dp))
|
|
||||||
Text("${if (message.isEdited) "edited " else ""}$time", color = timeColor, fontSize = 11.sp, modifier = Modifier.align(Alignment.End))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// endregion
|
|
||||||
|
|
||||||
// region InputBar
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MessageInputBar(
|
|
||||||
text: String,
|
|
||||||
onTextChange: (String) -> Unit,
|
|
||||||
onSend: () -> Unit,
|
|
||||||
isSending: Boolean,
|
|
||||||
showEmojiPicker: Boolean = false,
|
|
||||||
onToggleEmoji: () -> Unit = {},
|
|
||||||
focusRequester: androidx.compose.ui.focus.FocusRequester? = null,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val canSend = text.isNotBlank() && !isSending
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
// Emoji toggle
|
|
||||||
IconButton(onClick = onToggleEmoji, modifier = Modifier.size(44.dp)) {
|
|
||||||
Icon(
|
|
||||||
if (showEmojiPicker) Icons.Default.Keyboard else Icons.Outlined.EmojiEmotions,
|
|
||||||
contentDescription = "Emoji",
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Input field with attach inside
|
|
||||||
TextField(
|
|
||||||
value = text,
|
|
||||||
onValueChange = onTextChange,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier),
|
|
||||||
placeholder = {
|
|
||||||
Text("Message...", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
},
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
|
||||||
keyboardActions = KeyboardActions(onSend = { onSend() }),
|
|
||||||
maxLines = 4,
|
|
||||||
trailingIcon = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.AttachFile,
|
|
||||||
contentDescription = "Attach",
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(24.dp)
|
|
||||||
.clickable { /* TODO: attach */ }
|
|
||||||
)
|
|
||||||
},
|
|
||||||
colors = TextFieldDefaults.colors(
|
|
||||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
focusedIndicatorColor = Color.Transparent,
|
|
||||||
unfocusedIndicatorColor = Color.Transparent,
|
|
||||||
cursorColor = MaterialTheme.colorScheme.primary
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(24.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
|
|
||||||
// Send
|
|
||||||
IconButton(
|
|
||||||
onClick = onSend,
|
|
||||||
enabled = canSend,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(44.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(
|
|
||||||
if (canSend)
|
|
||||||
Brush.linearGradient(
|
|
||||||
listOf(
|
|
||||||
MaterialTheme.colorScheme.primary,
|
|
||||||
MaterialTheme.colorScheme.primaryContainer
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Brush.linearGradient(
|
|
||||||
listOf(
|
|
||||||
MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.Send,
|
|
||||||
contentDescription = "Send",
|
|
||||||
tint = if (canSend)
|
|
||||||
MaterialTheme.colorScheme.onPrimary
|
|
||||||
else
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// endregion
|
|
||||||
|
|||||||
@ -0,0 +1,81 @@
|
|||||||
|
package org.yobble.messenger.presentation.chat.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.ClipboardManager
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.yobble.messenger.data.remote.dto.MessageItemDto
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun MessageActionsSheet(msg: MessageItemDto?, currentUserId: String, clipboard: ClipboardManager, onDismiss: () -> Unit, onEdit: (MessageItemDto) -> Unit, onDelete: (MessageItemDto) -> Unit) {
|
||||||
|
if (msg == null) return
|
||||||
|
val isOwn = msg.senderId == currentUserId
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss, containerColor = MaterialTheme.colorScheme.surfaceVariant) {
|
||||||
|
Column(Modifier.padding(bottom = 32.dp)) {
|
||||||
|
if (!msg.content.isNullOrBlank()) {
|
||||||
|
ActionRow(Icons.Default.ContentCopy, "Copy") { clipboard.setText(AnnotatedString(msg.content)); onDismiss() }
|
||||||
|
}
|
||||||
|
if (isOwn && !msg.content.isNullOrBlank()) {
|
||||||
|
ActionRow(Icons.Default.Edit, "Edit") { onEdit(msg) }
|
||||||
|
}
|
||||||
|
ActionRow(Icons.Default.Delete, "Delete", MaterialTheme.colorScheme.error) { onDelete(msg) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DeleteMessageDialog(msg: MessageItemDto?, currentUserId: String, deleteForAll: Boolean, onDeleteForAllChange: (Boolean) -> Unit, onConfirm: (MessageItemDto) -> Unit, onDismiss: () -> Unit) {
|
||||||
|
if (msg == null) return
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Delete message?") },
|
||||||
|
text = {
|
||||||
|
if (msg.senderId == currentUserId) {
|
||||||
|
Row(Modifier.fillMaxWidth().clickable { onDeleteForAllChange(!deleteForAll) }, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(deleteForAll, onDeleteForAllChange)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text("Delete for everyone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = { onConfirm(msg) }) { Text("Delete", color = MaterialTheme.colorScheme.error) } },
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun EditMessageDialog(msg: MessageItemDto?, text: String, onTextChange: (String) -> Unit, onConfirm: (MessageItemDto) -> Unit, onDismiss: () -> Unit) {
|
||||||
|
if (msg == null) return
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Edit message") },
|
||||||
|
text = {
|
||||||
|
TextField(text, onTextChange, Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = TextFieldDefaults.colors(focusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedContainerColor = MaterialTheme.colorScheme.surface, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent))
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = { onConfirm(msg) }, enabled = text.isNotBlank()) { Text("Save") } },
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ActionRow(icon: ImageVector, label: String, color: Color = MaterialTheme.colorScheme.onSurface, onClick: () -> Unit) {
|
||||||
|
Row(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 24.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(icon, null, tint = color, modifier = Modifier.size(22.dp))
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyLarge, color = color)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
package org.yobble.messenger.presentation.chat.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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.Brush
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.yobble.messenger.data.remote.dto.MessageItemDto
|
||||||
|
import org.yobble.messenger.util.formatUtcToLocalTime
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun MessageBubble(message: MessageItemDto, isOutgoing: Boolean, onLongClick: () -> Unit = {}) {
|
||||||
|
val primary = MaterialTheme.colorScheme.primary
|
||||||
|
val primaryContainer = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
val textColor = if (isOutgoing) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
|
||||||
|
val timeColor = if (isOutgoing) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
val shape = if (isOutgoing) RoundedCornerShape(18.dp, 18.dp, 4.dp, 18.dp) else RoundedCornerShape(18.dp, 18.dp, 18.dp, 4.dp)
|
||||||
|
val time = formatUtcToLocalTime(message.createdAt)
|
||||||
|
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start) {
|
||||||
|
Box(
|
||||||
|
Modifier.widthIn(max = 280.dp).clip(shape)
|
||||||
|
.combinedClickable(onClick = {}, onLongClick = onLongClick)
|
||||||
|
.then(if (isOutgoing) Modifier.background(Brush.linearGradient(listOf(primary, primaryContainer))) else Modifier.background(MaterialTheme.colorScheme.surfaceVariant))
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
if (!message.content.isNullOrBlank()) Text(message.content, color = textColor, fontSize = 15.sp)
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Text("${if (message.isEdited) "edited " else ""}$time", color = timeColor, fontSize = 11.sp, modifier = Modifier.align(Alignment.End))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,126 @@
|
|||||||
|
package org.yobble.messenger.presentation.chat.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.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.AttachFile
|
||||||
|
import androidx.compose.material.icons.filled.Keyboard
|
||||||
|
import androidx.compose.material.icons.outlined.EmojiEmotions
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
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.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun MessageInputBar(
|
||||||
|
text: String,
|
||||||
|
onTextChange: (String) -> Unit,
|
||||||
|
onSend: () -> Unit,
|
||||||
|
isSending: Boolean,
|
||||||
|
showEmojiPicker: Boolean = false,
|
||||||
|
onToggleEmoji: () -> Unit = {},
|
||||||
|
focusRequester: FocusRequester? = null,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val canSend = text.isNotBlank() && !isSending
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Emoji toggle
|
||||||
|
IconButton(onClick = onToggleEmoji, modifier = Modifier.size(44.dp)) {
|
||||||
|
Icon(
|
||||||
|
if (showEmojiPicker) Icons.Default.Keyboard else Icons.Outlined.EmojiEmotions,
|
||||||
|
contentDescription = "Emoji",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input field with attach inside
|
||||||
|
TextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = onTextChange,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier),
|
||||||
|
placeholder = {
|
||||||
|
Text("Message...", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||||
|
keyboardActions = KeyboardActions(onSend = { onSend() }),
|
||||||
|
maxLines = 4,
|
||||||
|
trailingIcon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.AttachFile,
|
||||||
|
contentDescription = "Attach",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.clickable { /* TODO: attach */ }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
cursorColor = MaterialTheme.colorScheme.primary
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
|
||||||
|
// Send
|
||||||
|
IconButton(
|
||||||
|
onClick = onSend,
|
||||||
|
enabled = canSend,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
if (canSend)
|
||||||
|
Brush.linearGradient(
|
||||||
|
listOf(
|
||||||
|
MaterialTheme.colorScheme.primary,
|
||||||
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Brush.linearGradient(
|
||||||
|
listOf(
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.Send,
|
||||||
|
contentDescription = "Send",
|
||||||
|
tint = if (canSend)
|
||||||
|
MaterialTheme.colorScheme.onPrimary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -85,5 +85,5 @@ fun InitialsAvatar(
|
|||||||
|
|
||||||
fun buildAvatarUrl(userId: String?, fileId: String?): String? {
|
fun buildAvatarUrl(userId: String?, fileId: String?): String? {
|
||||||
if (userId.isNullOrBlank() || fileId.isNullOrBlank()) return null
|
if (userId.isNullOrBlank() || fileId.isNullOrBlank()) return null
|
||||||
return "${BuildConfig.BASE_URL}v1/storage/download/avatar/$userId?file_id=$fileId"
|
return "${BuildConfig.BASE_URL}v1/storage/avatar/download/$userId?file_id=$fileId"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,10 +7,7 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
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.Delete
|
|
||||||
import androidx.compose.material.icons.filled.Edit
|
|
||||||
import androidx.compose.material.icons.filled.PersonAdd
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
@ -26,6 +23,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import org.yobble.messenger.data.remote.dto.ContactInfoDto
|
import org.yobble.messenger.data.remote.dto.ContactInfoDto
|
||||||
import org.yobble.messenger.presentation.common.UserAvatar
|
import org.yobble.messenger.presentation.common.UserAvatar
|
||||||
|
import org.yobble.messenger.presentation.contacts.components.AddContactDialog
|
||||||
|
import org.yobble.messenger.presentation.contacts.components.RenameContactDialog
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ContactsScreen(
|
fun ContactsScreen(
|
||||||
@ -279,95 +278,3 @@ private fun ContactItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AddContactDialog(
|
|
||||||
query: String,
|
|
||||||
onQueryChange: (String) -> Unit,
|
|
||||||
onAdd: () -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
isAdding: Boolean
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text("Add contact") },
|
|
||||||
text = {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = query,
|
|
||||||
onValueChange = onQueryChange,
|
|
||||||
label = { Text("Login") },
|
|
||||||
placeholder = { Text("username") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = onAdd,
|
|
||||||
enabled = query.isNotBlank() && !isAdding
|
|
||||||
) {
|
|
||||||
if (isAdding) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text("Add")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text("Cancel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RenameContactDialog(
|
|
||||||
currentName: String,
|
|
||||||
onNameChange: (String) -> Unit,
|
|
||||||
onSave: () -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
isSaving: Boolean
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text("Rename contact") },
|
|
||||||
text = {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = currentName,
|
|
||||||
onValueChange = onNameChange,
|
|
||||||
label = { Text("Display name") },
|
|
||||||
placeholder = { Text("Custom name") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = onSave,
|
|
||||||
enabled = !isSaving
|
|
||||||
) {
|
|
||||||
if (isSaving) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text("Save")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text("Cancel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -0,0 +1,101 @@
|
|||||||
|
package org.yobble.messenger.presentation.contacts.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun AddContactDialog(
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
onAdd: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
isAdding: Boolean
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Add contact") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
label = { Text("Login") },
|
||||||
|
placeholder = { Text("username") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = onAdd,
|
||||||
|
enabled = query.isNotBlank() && !isAdding
|
||||||
|
) {
|
||||||
|
if (isAdding) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Add")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun RenameContactDialog(
|
||||||
|
currentName: String,
|
||||||
|
onNameChange: (String) -> Unit,
|
||||||
|
onSave: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
isSaving: Boolean
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Rename contact") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = currentName,
|
||||||
|
onValueChange = onNameChange,
|
||||||
|
label = { Text("Display name") },
|
||||||
|
placeholder = { Text("Custom name") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = onSave,
|
||||||
|
enabled = !isSaving
|
||||||
|
) {
|
||||||
|
if (isSaving) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -6,65 +6,48 @@ import androidx.compose.animation.slideInHorizontally
|
|||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.combinedClickable
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.layout.windowInsetsBottomHeight
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Contacts
|
import androidx.compose.material.icons.filled.Contacts
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Verified
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
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
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.yobble.messenger.data.remote.dto.PrivateChatListItemDto
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.material.icons.filled.Add
|
|
||||||
import androidx.compose.material.icons.filled.Menu
|
|
||||||
import androidx.compose.material.icons.filled.PersonAdd
|
|
||||||
import androidx.compose.material.icons.filled.Settings
|
|
||||||
import androidx.compose.material3.DrawerValue
|
|
||||||
import androidx.compose.material3.ModalDrawerSheet
|
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
|
||||||
import androidx.compose.material3.NavigationDrawerItem
|
|
||||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
|
||||||
import androidx.compose.material3.rememberDrawerState
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import org.yobble.messenger.data.local.SessionManager
|
|
||||||
import org.yobble.messenger.presentation.accounts.AccountSwitcherViewModel
|
import org.yobble.messenger.presentation.accounts.AccountSwitcherViewModel
|
||||||
import org.yobble.messenger.presentation.common.InitialsAvatar
|
|
||||||
import org.yobble.messenger.presentation.common.UserAvatar
|
import org.yobble.messenger.presentation.common.UserAvatar
|
||||||
import org.yobble.messenger.presentation.contacts.ContactsScreen
|
import org.yobble.messenger.presentation.contacts.ContactsScreen
|
||||||
|
import org.yobble.messenger.presentation.main.components.ChatsContent
|
||||||
|
import org.yobble.messenger.presentation.main.components.DrawerContent
|
||||||
|
import org.yobble.messenger.presentation.main.components.DrawerTab
|
||||||
import org.yobble.messenger.presentation.settings.SettingsScreen
|
import org.yobble.messenger.presentation.settings.SettingsScreen
|
||||||
import org.yobble.messenger.util.formatUtcToLocalTime
|
|
||||||
|
|
||||||
private const val SWIPE_NAVIGATION_ENABLED = true
|
private const val SWIPE_NAVIGATION_ENABLED = true
|
||||||
|
|
||||||
@ -105,7 +88,7 @@ fun HomeScreen(
|
|||||||
// Sync tabs when switching nav style
|
// Sync tabs when switching nav style
|
||||||
LaunchedEffect(useDrawer) {
|
LaunchedEffect(useDrawer) {
|
||||||
if (useDrawer) {
|
if (useDrawer) {
|
||||||
drawerSelectedTab = pagerState.currentPage
|
drawerSelectedTab = 0 // Always start on Chats in drawer mode
|
||||||
} else {
|
} else {
|
||||||
pagerState.scrollToPage(drawerSelectedTab)
|
pagerState.scrollToPage(drawerSelectedTab)
|
||||||
}
|
}
|
||||||
@ -160,167 +143,22 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var showDrawerAccountPopup by remember { mutableStateOf(false) }
|
val drawerTabs = tabs.mapIndexed { index, tab -> DrawerTab(tab.label, index) }
|
||||||
|
|
||||||
val drawerContent: @Composable () -> Unit = {
|
val drawerContent: @Composable () -> Unit = {
|
||||||
ModalDrawerSheet(
|
DrawerContent(
|
||||||
drawerContainerColor = MaterialTheme.colorScheme.surface
|
uiState = uiState,
|
||||||
) {
|
accountsState = accountsState,
|
||||||
// Account header
|
selectedTab = selectedTab,
|
||||||
val acc = uiState.activeAccount
|
tabs = drawerTabs,
|
||||||
Box(
|
onAccountSwitched = onAccountSwitched,
|
||||||
modifier = Modifier
|
onAddAccount = onAddAccount,
|
||||||
.fillMaxWidth()
|
onSwitchAccount = { accountsViewModel.switchTo(it) },
|
||||||
.combinedClickable(
|
onPrepareNewAccountLogin = { accountsViewModel.prepareNewAccountLogin() },
|
||||||
onClick = {
|
coroutineScope = coroutineScope,
|
||||||
coroutineScope.launch {
|
drawerState = drawerState,
|
||||||
drawerState.close()
|
onDrawerSelectedTabChanged = { drawerSelectedTab = it }
|
||||||
drawerSelectedTab = tabs.indexOf(HomeTab.SETTINGS)
|
)
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongClick = { showDrawerAccountPopup = true }
|
|
||||||
)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 20.dp)
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
if (acc != null) {
|
|
||||||
UserAvatar(
|
|
||||||
userId = acc.userId,
|
|
||||||
fileId = acc.avatarFileId,
|
|
||||||
displayName = acc.displayName ?: acc.login ?: "U",
|
|
||||||
size = 44.dp,
|
|
||||||
fontSize = 18.sp
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
acc.displayName ?: acc.login ?: "Account",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
if (acc.login != null) {
|
|
||||||
Text(
|
|
||||||
"@${acc.login}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DropdownMenu(
|
|
||||||
expanded = showDrawerAccountPopup,
|
|
||||||
onDismissRequest = { showDrawerAccountPopup = false },
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
) {
|
|
||||||
accountsState.accounts.forEach { account ->
|
|
||||||
val isActive = account.accountId == accountsState.activeAccountId
|
|
||||||
val name = account.displayName ?: account.login ?: "Account"
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
UserAvatar(account.userId, account.avatarFileId, name, 28.dp, 11.sp)
|
|
||||||
Spacer(Modifier.width(10.dp))
|
|
||||||
Text(
|
|
||||||
name,
|
|
||||||
fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal,
|
|
||||||
color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
showDrawerAccountPopup = false
|
|
||||||
if (!isActive) {
|
|
||||||
accountsViewModel.switchTo(account.accountId)
|
|
||||||
onAccountSwitched()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f), modifier = Modifier.padding(vertical = 4.dp))
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Icon(Icons.Default.PersonAdd, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(28.dp).padding(2.dp))
|
|
||||||
Spacer(Modifier.width(10.dp))
|
|
||||||
Text("Add account", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Medium)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
showDrawerAccountPopup = false
|
|
||||||
accountsViewModel.prepareNewAccountLogin()
|
|
||||||
onAddAccount()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f))
|
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
|
|
||||||
// Chats
|
|
||||||
NavigationDrawerItem(
|
|
||||||
label = { Text("Chats") },
|
|
||||||
selected = selectedTab == tabs.indexOf(HomeTab.CHATS),
|
|
||||||
onClick = {
|
|
||||||
coroutineScope.launch {
|
|
||||||
drawerState.close()
|
|
||||||
drawerSelectedTab = tabs.indexOf(HomeTab.CHATS)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) },
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
|
||||||
colors = NavigationDrawerItemDefaults.colors(
|
|
||||||
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Contacts
|
|
||||||
NavigationDrawerItem(
|
|
||||||
label = { Text("Contacts") },
|
|
||||||
selected = selectedTab == tabs.indexOf(HomeTab.CONTACTS),
|
|
||||||
onClick = {
|
|
||||||
coroutineScope.launch {
|
|
||||||
drawerState.close()
|
|
||||||
drawerSelectedTab = tabs.indexOf(HomeTab.CONTACTS)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon = { Icon(Icons.Default.Contacts, contentDescription = null) },
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
|
||||||
colors = NavigationDrawerItemDefaults.colors(
|
|
||||||
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Settings
|
|
||||||
NavigationDrawerItem(
|
|
||||||
label = { Text("Settings") },
|
|
||||||
selected = selectedTab == tabs.indexOf(HomeTab.SETTINGS),
|
|
||||||
onClick = {
|
|
||||||
coroutineScope.launch {
|
|
||||||
drawerState.close()
|
|
||||||
drawerSelectedTab = tabs.indexOf(HomeTab.SETTINGS)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon = { Icon(Icons.Default.Settings, contentDescription = null) },
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
|
||||||
colors = NavigationDrawerItemDefaults.colors(
|
|
||||||
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.weight(1f))
|
|
||||||
|
|
||||||
// Version
|
|
||||||
Text(
|
|
||||||
text = "Yobble v${org.yobble.messenger.BuildConfig.VERSION_NAME}",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val scaffoldContent: @Composable () -> Unit = {
|
val scaffoldContent: @Composable () -> Unit = {
|
||||||
@ -378,10 +216,18 @@ fun HomeScreen(
|
|||||||
} else {
|
} else {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
if (!uiState.isConnected) {
|
||||||
tabs[selectedTab].label,
|
Text(
|
||||||
fontWeight = FontWeight.Bold
|
"Connecting...",
|
||||||
)
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
tabs[selectedTab].label,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
@ -506,6 +352,15 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Bottom navigation bar
|
// Bottom navigation bar
|
||||||
|
// Navigation bar background (covers system nav area in drawer mode)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.windowInsetsBottomHeight(WindowInsets.navigationBars)
|
||||||
|
)
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = !useDrawer,
|
visible = !useDrawer,
|
||||||
enter = slideInVertically(initialOffsetY = { it }),
|
enter = slideInVertically(initialOffsetY = { it }),
|
||||||
@ -726,212 +581,3 @@ private fun TabContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ChatsContent(
|
|
||||||
uiState: HomeUiState,
|
|
||||||
onNavigateToChat: (String) -> Unit,
|
|
||||||
selectedChatIds: Set<String>,
|
|
||||||
onToggleChatSelection: (String) -> Unit,
|
|
||||||
onLoadMore: () -> Unit,
|
|
||||||
bottomPadding: androidx.compose.ui.unit.Dp
|
|
||||||
) {
|
|
||||||
if (uiState.isLoading && uiState.chats.isEmpty()) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
|
||||||
}
|
|
||||||
} else if (uiState.chats.isEmpty()) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(
|
|
||||||
"No chats yet",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
"Start a new conversation",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
|
|
||||||
val hasMore = uiState.hasMore
|
|
||||||
val isLoading = uiState.isLoading
|
|
||||||
val shouldLoadMore by remember(hasMore, isLoading) {
|
|
||||||
derivedStateOf {
|
|
||||||
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
|
||||||
val totalItems = listState.layoutInfo.totalItemsCount
|
|
||||||
lastVisible >= totalItems - 3 && hasMore && !isLoading
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchedEffect(shouldLoadMore) {
|
|
||||||
if (shouldLoadMore) onLoadMore()
|
|
||||||
}
|
|
||||||
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
state = listState,
|
|
||||||
contentPadding = PaddingValues(bottom = bottomPadding)
|
|
||||||
) {
|
|
||||||
items(uiState.chats, key = { it.chatId }) { chat ->
|
|
||||||
ChatListItem(
|
|
||||||
chat = chat,
|
|
||||||
isSelected = chat.chatId in selectedChatIds,
|
|
||||||
inSelectionMode = selectedChatIds.isNotEmpty(),
|
|
||||||
onClick = {
|
|
||||||
if (selectedChatIds.isNotEmpty()) {
|
|
||||||
onToggleChatSelection(chat.chatId)
|
|
||||||
} else {
|
|
||||||
onNavigateToChat(chat.chatId)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongClick = { onToggleChatSelection(chat.chatId) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (uiState.isLoading && uiState.chats.isNotEmpty()) {
|
|
||||||
item {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
|
||||||
private fun ChatListItem(
|
|
||||||
chat: PrivateChatListItemDto,
|
|
||||||
isSelected: Boolean,
|
|
||||||
inSelectionMode: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
onLongClick: () -> Unit
|
|
||||||
) {
|
|
||||||
val chatData = chat.chatData
|
|
||||||
val displayName = chatData?.customName
|
|
||||||
?: chatData?.fullName
|
|
||||||
?: chatData?.login
|
|
||||||
?: chat.chatType.replaceFirstChar { it.uppercase() }
|
|
||||||
val isVerified = chatData?.verification != null
|
|
||||||
val lastMessageText = chat.lastMessage?.content ?: ""
|
|
||||||
val time = formatUtcToLocalTime(chat.lastMessage?.createdAt)
|
|
||||||
|
|
||||||
val bgColor = if (isSelected)
|
|
||||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
|
||||||
else
|
|
||||||
Color.Transparent
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(bgColor)
|
|
||||||
.combinedClickable(
|
|
||||||
onClick = onClick,
|
|
||||||
onLongClick = onLongClick
|
|
||||||
)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
UserAvatar(
|
|
||||||
userId = chatData?.userId,
|
|
||||||
fileId = chatData?.avatars?.current?.fileId,
|
|
||||||
displayName = displayName
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = displayName,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.weight(1f, fill = false)
|
|
||||||
)
|
|
||||||
if (isVerified) {
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Verified,
|
|
||||||
contentDescription = "Verified",
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = time,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = if (chat.unreadCount > 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 = lastMessageText,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
if (chat.unreadCount > 0) {
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.primary)
|
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = if (chat.unreadCount > 99) "99+" else chat.unreadCount.toString(),
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|||||||
@ -31,7 +31,8 @@ data class HomeUiState(
|
|||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val hasMore: Boolean = false,
|
val hasMore: Boolean = false,
|
||||||
val activeAccount: AccountInfo? = null,
|
val activeAccount: AccountInfo? = null,
|
||||||
val navStyle: String = "bottom_bar"
|
val navStyle: String = "bottom_bar",
|
||||||
|
val isConnected: Boolean = true
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed class HomeEvent {
|
sealed class HomeEvent {
|
||||||
@ -82,8 +83,13 @@ class HomeViewModel @Inject constructor(
|
|||||||
socketManager.events.collect { event ->
|
socketManager.events.collect { event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
is SocketEvent.NewMessage -> debouncedRefresh()
|
is SocketEvent.NewMessage -> debouncedRefresh()
|
||||||
is SocketEvent.Connected -> loadChats()
|
is SocketEvent.Connected -> {
|
||||||
is SocketEvent.Disconnected -> {}
|
_uiState.update { it.copy(isConnected = true) }
|
||||||
|
loadChats()
|
||||||
|
}
|
||||||
|
is SocketEvent.Disconnected -> {
|
||||||
|
_uiState.update { it.copy(isConnected = false) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,236 @@
|
|||||||
|
package org.yobble.messenger.presentation.main.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Verified
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
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.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.yobble.messenger.data.remote.dto.PrivateChatListItemDto
|
||||||
|
import org.yobble.messenger.presentation.common.UserAvatar
|
||||||
|
import org.yobble.messenger.presentation.main.HomeUiState
|
||||||
|
import org.yobble.messenger.util.formatUtcToLocalTime
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ChatsContent(
|
||||||
|
uiState: HomeUiState,
|
||||||
|
onNavigateToChat: (String) -> Unit,
|
||||||
|
selectedChatIds: Set<String>,
|
||||||
|
onToggleChatSelection: (String) -> Unit,
|
||||||
|
onLoadMore: () -> Unit,
|
||||||
|
bottomPadding: Dp
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading && uiState.chats.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
} else if (uiState.chats.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
"No chats yet",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"Start a new conversation",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
val hasMore = uiState.hasMore
|
||||||
|
val isLoading = uiState.isLoading
|
||||||
|
val shouldLoadMore by remember(hasMore, isLoading) {
|
||||||
|
derivedStateOf {
|
||||||
|
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||||
|
val totalItems = listState.layoutInfo.totalItemsCount
|
||||||
|
lastVisible >= totalItems - 3 && hasMore && !isLoading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(shouldLoadMore) {
|
||||||
|
if (shouldLoadMore) onLoadMore()
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
state = listState,
|
||||||
|
contentPadding = PaddingValues(bottom = bottomPadding)
|
||||||
|
) {
|
||||||
|
items(uiState.chats, key = { it.chatId }) { chat ->
|
||||||
|
ChatListItem(
|
||||||
|
chat = chat,
|
||||||
|
isSelected = chat.chatId in selectedChatIds,
|
||||||
|
inSelectionMode = selectedChatIds.isNotEmpty(),
|
||||||
|
onClick = {
|
||||||
|
if (selectedChatIds.isNotEmpty()) {
|
||||||
|
onToggleChatSelection(chat.chatId)
|
||||||
|
} else {
|
||||||
|
onNavigateToChat(chat.chatId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongClick = { onToggleChatSelection(chat.chatId) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (uiState.isLoading && uiState.chats.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun ChatListItem(
|
||||||
|
chat: PrivateChatListItemDto,
|
||||||
|
isSelected: Boolean,
|
||||||
|
inSelectionMode: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val chatData = chat.chatData
|
||||||
|
val displayName = chatData?.customName
|
||||||
|
?: chatData?.fullName
|
||||||
|
?: chatData?.login
|
||||||
|
?: chat.chatType.replaceFirstChar { it.uppercase() }
|
||||||
|
val isVerified = chatData?.verification != null
|
||||||
|
val lastMessageText = chat.lastMessage?.content ?: ""
|
||||||
|
val time = formatUtcToLocalTime(chat.lastMessage?.createdAt)
|
||||||
|
|
||||||
|
val bgColor = if (isSelected)
|
||||||
|
MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
||||||
|
else
|
||||||
|
Color.Transparent
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(bgColor)
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
UserAvatar(
|
||||||
|
userId = chatData?.userId,
|
||||||
|
fileId = chatData?.avatars?.current?.fileId,
|
||||||
|
displayName = displayName
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false)
|
||||||
|
)
|
||||||
|
if (isVerified) {
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Verified,
|
||||||
|
contentDescription = "Verified",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = time,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (chat.unreadCount > 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 = lastMessageText,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
if (chat.unreadCount > 0) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primary)
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (chat.unreadCount > 99) "99+" else chat.unreadCount.toString(),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,207 @@
|
|||||||
|
package org.yobble.messenger.presentation.main.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||||
|
import androidx.compose.material.icons.filled.Contacts
|
||||||
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.yobble.messenger.BuildConfig
|
||||||
|
import org.yobble.messenger.presentation.accounts.AccountSwitcherUiState
|
||||||
|
import org.yobble.messenger.presentation.common.UserAvatar
|
||||||
|
import org.yobble.messenger.presentation.main.HomeUiState
|
||||||
|
|
||||||
|
internal data class DrawerTab(
|
||||||
|
val label: String,
|
||||||
|
val index: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun DrawerContent(
|
||||||
|
uiState: HomeUiState,
|
||||||
|
accountsState: AccountSwitcherUiState,
|
||||||
|
selectedTab: Int,
|
||||||
|
tabs: List<DrawerTab>,
|
||||||
|
onAccountSwitched: () -> Unit,
|
||||||
|
onAddAccount: () -> Unit,
|
||||||
|
onSwitchAccount: (String) -> Unit,
|
||||||
|
onPrepareNewAccountLogin: () -> Unit,
|
||||||
|
coroutineScope: CoroutineScope,
|
||||||
|
drawerState: DrawerState,
|
||||||
|
onDrawerSelectedTabChanged: (Int) -> Unit
|
||||||
|
) {
|
||||||
|
var showDrawerAccountPopup by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
ModalDrawerSheet(
|
||||||
|
drawerContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
modifier = Modifier.widthIn(max = 280.dp)
|
||||||
|
) {
|
||||||
|
// Account header
|
||||||
|
val acc = uiState.activeAccount
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
drawerState.close()
|
||||||
|
// Navigate to Settings tab (last tab)
|
||||||
|
onDrawerSelectedTabChanged(tabs.last().index)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongClick = { showDrawerAccountPopup = true }
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 20.dp)
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (acc != null) {
|
||||||
|
UserAvatar(
|
||||||
|
userId = acc.userId,
|
||||||
|
fileId = acc.avatarFileId,
|
||||||
|
displayName = acc.displayName ?: acc.login ?: "U",
|
||||||
|
size = 44.dp,
|
||||||
|
fontSize = 18.sp
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
acc.displayName ?: acc.login ?: "Account",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
if (acc.login != null) {
|
||||||
|
Text(
|
||||||
|
"@${acc.login}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showDrawerAccountPopup,
|
||||||
|
onDismissRequest = { showDrawerAccountPopup = false },
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
) {
|
||||||
|
accountsState.accounts.forEach { account ->
|
||||||
|
val isActive = account.accountId == accountsState.activeAccountId
|
||||||
|
val name = account.displayName ?: account.login ?: "Account"
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
UserAvatar(account.userId, account.avatarFileId, name, 28.dp, 11.sp)
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showDrawerAccountPopup = false
|
||||||
|
if (!isActive) {
|
||||||
|
onSwitchAccount(account.accountId)
|
||||||
|
onAccountSwitched()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f), modifier = Modifier.padding(vertical = 4.dp))
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Default.PersonAdd, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(28.dp).padding(2.dp))
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Text("Add account", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Medium)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showDrawerAccountPopup = false
|
||||||
|
onPrepareNewAccountLogin()
|
||||||
|
onAddAccount()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f))
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Chats
|
||||||
|
NavigationDrawerItem(
|
||||||
|
label = { Text("Chats") },
|
||||||
|
selected = selectedTab == tabs[0].index,
|
||||||
|
onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
drawerState.close()
|
||||||
|
onDrawerSelectedTabChanged(tabs[0].index)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) },
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
colors = NavigationDrawerItemDefaults.colors(
|
||||||
|
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Contacts
|
||||||
|
NavigationDrawerItem(
|
||||||
|
label = { Text("Contacts") },
|
||||||
|
selected = selectedTab == tabs[1].index,
|
||||||
|
onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
drawerState.close()
|
||||||
|
onDrawerSelectedTabChanged(tabs[1].index)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = { Icon(Icons.Default.Contacts, contentDescription = null) },
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
colors = NavigationDrawerItemDefaults.colors(
|
||||||
|
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
NavigationDrawerItem(
|
||||||
|
label = { Text("Settings") },
|
||||||
|
selected = selectedTab == tabs[2].index,
|
||||||
|
onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
drawerState.close()
|
||||||
|
onDrawerSelectedTabChanged(tabs[2].index)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = { Icon(Icons.Default.Settings, contentDescription = null) },
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
colors = NavigationDrawerItemDefaults.colors(
|
||||||
|
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
|
// Version
|
||||||
|
Text(
|
||||||
|
text = "Yobble v${BuildConfig.VERSION_NAME}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,11 +3,9 @@ package org.yobble.messenger.presentation.profile
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.animateContentSize
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@ -17,30 +15,25 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|||||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||||
import androidx.compose.material.icons.filled.CameraAlt
|
import androidx.compose.material.icons.filled.CameraAlt
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.EmojiEvents
|
|
||||||
import androidx.compose.material.icons.filled.Lock
|
|
||||||
import androidx.compose.material.icons.filled.Star
|
|
||||||
import androidx.compose.material.icons.filled.Verified
|
import androidx.compose.material.icons.filled.Verified
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import org.yobble.messenger.data.remote.dto.AchievementItemDto
|
|
||||||
import org.yobble.messenger.presentation.common.FullScreenImageViewer
|
import org.yobble.messenger.presentation.common.FullScreenImageViewer
|
||||||
import org.yobble.messenger.presentation.common.UserAvatar
|
import org.yobble.messenger.presentation.common.UserAvatar
|
||||||
import org.yobble.messenger.presentation.common.buildAvatarUrl
|
import org.yobble.messenger.presentation.common.buildAvatarUrl
|
||||||
|
import org.yobble.messenger.presentation.profile.components.AchievementsSection
|
||||||
|
import org.yobble.messenger.presentation.profile.components.EditProfileSection
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@ -410,309 +403,6 @@ private fun ProfileStat(value: String, label: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun EditProfileSection(
|
|
||||||
fullName: String,
|
|
||||||
bio: String,
|
|
||||||
onFullNameChange: (String) -> Unit,
|
|
||||||
onBioChange: (String) -> Unit,
|
|
||||||
onSave: () -> Unit,
|
|
||||||
onCancel: () -> Unit,
|
|
||||||
isSaving: Boolean,
|
|
||||||
canSave: Boolean
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = fullName,
|
|
||||||
onValueChange = onFullNameChange,
|
|
||||||
label = { Text("Full name") },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true,
|
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
val bioMaxLength = 1024
|
|
||||||
val bioOverLimit = bio.length > bioMaxLength
|
|
||||||
OutlinedTextField(
|
|
||||||
value = bio,
|
|
||||||
onValueChange = onBioChange,
|
|
||||||
label = { Text("Bio") },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
minLines = 3,
|
|
||||||
maxLines = 5,
|
|
||||||
isError = bioOverLimit,
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
supportingText = {
|
|
||||||
Text(
|
|
||||||
text = "${bio.length}/$bioMaxLength",
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
textAlign = TextAlign.End,
|
|
||||||
color = if (bioOverLimit)
|
|
||||||
MaterialTheme.colorScheme.error
|
|
||||||
else
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = onCancel,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
enabled = !isSaving
|
|
||||||
) {
|
|
||||||
Text("Cancel")
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = onSave,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
enabled = !isSaving && canSave
|
|
||||||
) {
|
|
||||||
if (isSaving) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text("Save")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AchievementsSection(
|
|
||||||
achievements: Map<String, List<AchievementItemDto>>
|
|
||||||
) {
|
|
||||||
if (achievements.isEmpty()) return
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Achievements",
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
achievements.forEach { (category, items) ->
|
|
||||||
AchievementGroup(category = category, items = items)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AchievementGroup(
|
|
||||||
category: String,
|
|
||||||
items: List<AchievementItemDto>
|
|
||||||
) {
|
|
||||||
// First item = master achievement, rest = sub-achievements
|
|
||||||
val master = items.firstOrNull() ?: return
|
|
||||||
val others = items.drop(1)
|
|
||||||
val completedCount = items.count { it.isCompleted }
|
|
||||||
val masterColor = badgeColor(master.badgeType)
|
|
||||||
|
|
||||||
Card(
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(16.dp)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(14.dp)) {
|
|
||||||
// Category header + progress
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = category,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "$completedCount/${items.size}",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
|
||||||
|
|
||||||
// Master achievement — larger
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(44.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(masterColor.copy(alpha = if (master.isCompleted) 0.2f else 0.08f)),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
if (master.isCompleted) Icons.Default.EmojiEvents else Icons.Default.Lock,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = masterColor.copy(alpha = if (master.isCompleted) 1f else 0.4f),
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = master.name,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (master.isCompleted) 1f else 0.5f),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
if (!master.description.isNullOrBlank()) {
|
|
||||||
Text(
|
|
||||||
text = master.description,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sub-achievements — horizontal scroll, single row
|
|
||||||
if (others.isNotEmpty()) {
|
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
modifier = Modifier.horizontalScroll(rememberScrollState())
|
|
||||||
) {
|
|
||||||
others.forEach { achievement ->
|
|
||||||
SmallAchievementIcon(achievement)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SmallAchievementIcon(achievement: AchievementItemDto) {
|
|
||||||
val color = badgeColor(achievement.badgeType)
|
|
||||||
val alpha = if (achievement.isCompleted) 1f else 0.35f
|
|
||||||
var showTooltip by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Box {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
modifier = Modifier
|
|
||||||
.width(48.dp)
|
|
||||||
.clickable { showTooltip = true }
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(32.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(color.copy(alpha = if (achievement.isCompleted) 0.2f else 0.06f)),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
if (achievement.isCompleted) Icons.Default.EmojiEvents else Icons.Default.Lock,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = color.copy(alpha = alpha),
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
Text(
|
|
||||||
text = achievement.name,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
fontSize = 8.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
DropdownMenu(
|
|
||||||
expanded = showTooltip,
|
|
||||||
onDismissRequest = { showTooltip = false },
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).widthIn(max = 200.dp)) {
|
|
||||||
Text(
|
|
||||||
text = achievement.name,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = achievement.badgeType.replaceFirstChar { it.uppercase() },
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = color
|
|
||||||
)
|
|
||||||
if (!achievement.description.isNullOrBlank()) {
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = achievement.description,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (achievement.requiredProgress != null && achievement.requiredProgress > 0) {
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
val progress = (achievement.progress ?: 0).toFloat() / achievement.requiredProgress
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { progress.coerceIn(0f, 1f) },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(4.dp)
|
|
||||||
.clip(RoundedCornerShape(2.dp)),
|
|
||||||
color = color,
|
|
||||||
trackColor = MaterialTheme.colorScheme.outlineVariant,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "${achievement.progress ?: 0}/${achievement.requiredProgress}",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
fontSize = 10.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun badgeColor(badgeType: String): Color {
|
|
||||||
return when (badgeType) {
|
|
||||||
"bronze" -> Color(0xFFCD7F32)
|
|
||||||
"silver" -> Color(0xFF9E9E9E)
|
|
||||||
"gold" -> Color(0xFFFFD700)
|
|
||||||
"platinum" -> Color(0xFF7B8794)
|
|
||||||
"diamond" -> Color(0xFF00BCD4)
|
|
||||||
"legendary" -> Color(0xFFFF6F00)
|
|
||||||
else -> MaterialTheme.colorScheme.primary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatUtcToLocalDate(isoString: String): String {
|
private fun formatUtcToLocalDate(isoString: String): String {
|
||||||
return try {
|
return try {
|
||||||
val zonedUtc = java.time.ZonedDateTime.parse(isoString)
|
val zonedUtc = java.time.ZonedDateTime.parse(isoString)
|
||||||
|
|||||||
@ -0,0 +1,243 @@
|
|||||||
|
package org.yobble.messenger.presentation.profile.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.EmojiEvents
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
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
|
||||||
|
import org.yobble.messenger.data.remote.dto.AchievementItemDto
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun AchievementsSection(
|
||||||
|
achievements: Map<String, List<AchievementItemDto>>
|
||||||
|
) {
|
||||||
|
if (achievements.isEmpty()) return
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Achievements",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
achievements.forEach { (category, items) ->
|
||||||
|
AchievementGroup(category = category, items = items)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun AchievementGroup(
|
||||||
|
category: String,
|
||||||
|
items: List<AchievementItemDto>
|
||||||
|
) {
|
||||||
|
// First item = master achievement, rest = sub-achievements
|
||||||
|
val master = items.firstOrNull() ?: return
|
||||||
|
val others = items.drop(1)
|
||||||
|
val completedCount = items.count { it.isCompleted }
|
||||||
|
val masterColor = badgeColor(master.badgeType)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(14.dp)) {
|
||||||
|
// Category header + progress
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = category,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "$completedCount/${items.size}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
// Master achievement — larger
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(masterColor.copy(alpha = if (master.isCompleted) 0.2f else 0.08f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (master.isCompleted) Icons.Default.EmojiEvents else Icons.Default.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = masterColor.copy(alpha = if (master.isCompleted) 1f else 0.4f),
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = master.name,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (master.isCompleted) 1f else 0.5f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
if (!master.description.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = master.description,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub-achievements — horizontal scroll, single row
|
||||||
|
if (others.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.horizontalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
others.forEach { achievement ->
|
||||||
|
SmallAchievementIcon(achievement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun SmallAchievementIcon(achievement: AchievementItemDto) {
|
||||||
|
val color = badgeColor(achievement.badgeType)
|
||||||
|
val alpha = if (achievement.isCompleted) 1f else 0.35f
|
||||||
|
var showTooltip by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Box {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.width(48.dp)
|
||||||
|
.clickable { showTooltip = true }
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(color.copy(alpha = if (achievement.isCompleted) 0.2f else 0.06f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (achievement.isCompleted) Icons.Default.EmojiEvents else Icons.Default.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = color.copy(alpha = alpha),
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = achievement.name,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontSize = 8.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showTooltip,
|
||||||
|
onDismissRequest = { showTooltip = false },
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).widthIn(max = 200.dp)) {
|
||||||
|
Text(
|
||||||
|
text = achievement.name,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = achievement.badgeType.replaceFirstChar { it.uppercase() },
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = color
|
||||||
|
)
|
||||||
|
if (!achievement.description.isNullOrBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = achievement.description,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (achievement.requiredProgress != null && achievement.requiredProgress > 0) {
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
val progress = (achievement.progress ?: 0).toFloat() / achievement.requiredProgress
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { progress.coerceIn(0f, 1f) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(4.dp)
|
||||||
|
.clip(RoundedCornerShape(2.dp)),
|
||||||
|
color = color,
|
||||||
|
trackColor = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${achievement.progress ?: 0}/${achievement.requiredProgress}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontSize = 10.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun badgeColor(badgeType: String): Color {
|
||||||
|
return when (badgeType) {
|
||||||
|
"bronze" -> Color(0xFFCD7F32)
|
||||||
|
"silver" -> Color(0xFF9E9E9E)
|
||||||
|
"gold" -> Color(0xFFFFD700)
|
||||||
|
"platinum" -> Color(0xFF7B8794)
|
||||||
|
"diamond" -> Color(0xFF00BCD4)
|
||||||
|
"legendary" -> Color(0xFFFF6F00)
|
||||||
|
else -> MaterialTheme.colorScheme.primary
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
package org.yobble.messenger.presentation.profile.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun EditProfileSection(
|
||||||
|
fullName: String,
|
||||||
|
bio: String,
|
||||||
|
onFullNameChange: (String) -> Unit,
|
||||||
|
onBioChange: (String) -> Unit,
|
||||||
|
onSave: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
isSaving: Boolean,
|
||||||
|
canSave: Boolean
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = fullName,
|
||||||
|
onValueChange = onFullNameChange,
|
||||||
|
label = { Text("Full name") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
val bioMaxLength = 1024
|
||||||
|
val bioOverLimit = bio.length > bioMaxLength
|
||||||
|
OutlinedTextField(
|
||||||
|
value = bio,
|
||||||
|
onValueChange = onBioChange,
|
||||||
|
label = { Text("Bio") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
minLines = 3,
|
||||||
|
maxLines = 5,
|
||||||
|
isError = bioOverLimit,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
supportingText = {
|
||||||
|
Text(
|
||||||
|
text = "${bio.length}/$bioMaxLength",
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
color = if (bioOverLimit)
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onCancel,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
enabled = !isSaving
|
||||||
|
) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = onSave,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
enabled = !isSaving && canSave
|
||||||
|
) {
|
||||||
|
if (isSaving) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,11 +8,9 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Block
|
import androidx.compose.material.icons.filled.Block
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material.icons.filled.FolderOpen
|
import androidx.compose.material.icons.filled.FolderOpen
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
@ -22,18 +20,15 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import org.yobble.messenger.data.local.AccountInfo
|
|
||||||
import org.yobble.messenger.presentation.accounts.AccountSwitcherViewModel
|
import org.yobble.messenger.presentation.accounts.AccountSwitcherViewModel
|
||||||
import org.yobble.messenger.presentation.common.InitialsAvatar
|
import org.yobble.messenger.presentation.settings.components.AccountItem
|
||||||
import org.yobble.messenger.presentation.common.UserAvatar
|
import org.yobble.messenger.presentation.settings.components.ProfileSection
|
||||||
|
import org.yobble.messenger.presentation.settings.components.SettingsMenuItem
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
@ -238,173 +233,3 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ProfileSection(
|
|
||||||
account: AccountInfo,
|
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
val displayName = account.displayName ?: account.login ?: "Account"
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.padding(vertical = 20.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
UserAvatar(
|
|
||||||
userId = account.userId,
|
|
||||||
fileId = account.avatarFileId,
|
|
||||||
displayName = displayName,
|
|
||||||
size = 80.dp,
|
|
||||||
fontSize = 32.sp
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = displayName,
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
|
|
||||||
if (account.login != null) {
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
Text(
|
|
||||||
text = "@${account.login}",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!account.bio.isNullOrBlank()) {
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
Text(
|
|
||||||
text = account.bio,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AccountItem(
|
|
||||||
account: AccountInfo,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
onRemove: () -> Unit
|
|
||||||
) {
|
|
||||||
val displayName = account.displayName ?: account.login ?: "Account"
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.padding(vertical = 10.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
UserAvatar(
|
|
||||||
userId = account.userId,
|
|
||||||
fileId = account.avatarFileId,
|
|
||||||
displayName = displayName,
|
|
||||||
size = 40.dp,
|
|
||||||
fontSize = 16.sp
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = displayName,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
if (account.login != null) {
|
|
||||||
Text(
|
|
||||||
text = "@${account.login}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
IconButton(onClick = onRemove, modifier = Modifier.size(36.dp)) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Close,
|
|
||||||
contentDescription = "Remove",
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SettingsMenuItem(
|
|
||||||
icon: ImageVector,
|
|
||||||
title: String,
|
|
||||||
subtitle: String,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
showDivider: Boolean = true
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(36.dp)
|
|
||||||
.clip(RoundedCornerShape(10.dp))
|
|
||||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = subtitle,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (showDivider) {
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
|
||||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f),
|
|
||||||
thickness = 0.5.dp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -0,0 +1,191 @@
|
|||||||
|
package org.yobble.messenger.presentation.settings.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
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.vector.ImageVector
|
||||||
|
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 org.yobble.messenger.data.local.AccountInfo
|
||||||
|
import org.yobble.messenger.presentation.common.UserAvatar
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ProfileSection(
|
||||||
|
account: AccountInfo,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val displayName = account.displayName ?: account.login ?: "Account"
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(vertical = 20.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
UserAvatar(
|
||||||
|
userId = account.userId,
|
||||||
|
fileId = account.avatarFileId,
|
||||||
|
displayName = displayName,
|
||||||
|
size = 80.dp,
|
||||||
|
fontSize = 32.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
|
||||||
|
if (account.login != null) {
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = "@${account.login}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!account.bio.isNullOrBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
text = account.bio,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun AccountItem(
|
||||||
|
account: AccountInfo,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onRemove: () -> Unit
|
||||||
|
) {
|
||||||
|
val displayName = account.displayName ?: account.login ?: "Account"
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
UserAvatar(
|
||||||
|
userId = account.userId,
|
||||||
|
fileId = account.avatarFileId,
|
||||||
|
displayName = displayName,
|
||||||
|
size = 40.dp,
|
||||||
|
fontSize = 16.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
if (account.login != null) {
|
||||||
|
Text(
|
||||||
|
text = "@${account.login}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = onRemove, modifier = Modifier.size(36.dp)) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = "Remove",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun SettingsMenuItem(
|
||||||
|
icon: ImageVector,
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
showDivider: Boolean = true
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showDivider) {
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f),
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user