[feat/fix]:

- хз чет пофиксил
This commit is contained in:
YaAndreyIgorevich 2026-04-15 05:24:28 +07:00
parent 4f3df5e440
commit 105b0d5bad
17 changed files with 1391 additions and 1212 deletions

View File

@ -10,7 +10,7 @@ import retrofit2.http.Part
interface StorageApi {
@Multipart
@POST("v1/storage/upload/avatar")
@POST("v1/storage/avatar/upload")
suspend fun uploadAvatar(
@Part file: MultipartBody.Part
): Response<UploadAvatarResponseDto>

View File

@ -1,27 +1,14 @@
package org.yobble.messenger.presentation.chat
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.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.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.outlined.EmojiEmotions
import androidx.compose.material.icons.filled.Verified
import androidx.compose.ui.viewinterop.AndroidView
import androidx.emoji2.emojipicker.EmojiPickerView
@ -29,15 +16,9 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.AnnotatedString
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.graphics.Brush
import androidx.compose.ui.graphics.Color
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.unit.dp
import androidx.compose.ui.unit.sp
@ -46,9 +27,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
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.util.formatLastSeen
import org.yobble.messenger.util.formatUtcToLocalTime
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
@ -303,204 +288,3 @@ private fun ChatTopBar(uiState: ChatUiState, onNavigateBack: () -> Unit, onNavig
}
// 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

View File

@ -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)
}
}

View File

@ -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))
}
}
}
}

View File

@ -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)
)
}
}
}

View File

@ -85,5 +85,5 @@ fun InitialsAvatar(
fun buildAvatarUrl(userId: String?, fileId: String?): String? {
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"
}

View File

@ -7,10 +7,7 @@ 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.RoundedCornerShape
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.material3.*
import androidx.compose.runtime.*
@ -26,6 +23,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import org.yobble.messenger.data.remote.dto.ContactInfoDto
import org.yobble.messenger.presentation.common.UserAvatar
import org.yobble.messenger.presentation.contacts.components.AddContactDialog
import org.yobble.messenger.presentation.contacts.components.RenameContactDialog
@Composable
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")
}
}
)
}

View File

@ -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")
}
}
)
}

View File

@ -6,65 +6,48 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
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.layout.windowInsetsBottomHeight
import androidx.compose.foundation.pager.HorizontalPager
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.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
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.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
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.common.InitialsAvatar
import org.yobble.messenger.presentation.common.UserAvatar
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.util.formatUtcToLocalTime
private const val SWIPE_NAVIGATION_ENABLED = true
@ -105,7 +88,7 @@ fun HomeScreen(
// Sync tabs when switching nav style
LaunchedEffect(useDrawer) {
if (useDrawer) {
drawerSelectedTab = pagerState.currentPage
drawerSelectedTab = 0 // Always start on Chats in drawer mode
} else {
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 = {
ModalDrawerSheet(
drawerContainerColor = MaterialTheme.colorScheme.surface
) {
// Account header
val acc = uiState.activeAccount
Box(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = {
coroutineScope.launch {
drawerState.close()
drawerSelectedTab = tabs.indexOf(HomeTab.SETTINGS)
}
},
onLongClick = { showDrawerAccountPopup = true }
DrawerContent(
uiState = uiState,
accountsState = accountsState,
selectedTab = selectedTab,
tabs = drawerTabs,
onAccountSwitched = onAccountSwitched,
onAddAccount = onAddAccount,
onSwitchAccount = { accountsViewModel.switchTo(it) },
onPrepareNewAccountLogin = { accountsViewModel.prepareNewAccountLogin() },
coroutineScope = coroutineScope,
drawerState = drawerState,
onDrawerSelectedTabChanged = { drawerSelectedTab = it }
)
.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 = {
@ -378,10 +216,18 @@ fun HomeScreen(
} else {
TopAppBar(
title = {
if (!uiState.isConnected) {
Text(
"Connecting...",
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Text(
tabs[selectedTab].label,
fontWeight = FontWeight.Bold
)
}
},
navigationIcon = {
AnimatedVisibility(
@ -506,6 +352,15 @@ fun HomeScreen(
}
// 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(
visible = !useDrawer,
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
)
}
}
}
}
}
}

View File

@ -31,7 +31,8 @@ data class HomeUiState(
val isLoading: Boolean = false,
val hasMore: Boolean = false,
val activeAccount: AccountInfo? = null,
val navStyle: String = "bottom_bar"
val navStyle: String = "bottom_bar",
val isConnected: Boolean = true
)
sealed class HomeEvent {
@ -82,8 +83,13 @@ class HomeViewModel @Inject constructor(
socketManager.events.collect { event ->
when (event) {
is SocketEvent.NewMessage -> debouncedRefresh()
is SocketEvent.Connected -> loadChats()
is SocketEvent.Disconnected -> {}
is SocketEvent.Connected -> {
_uiState.update { it.copy(isConnected = true) }
loadChats()
}
is SocketEvent.Disconnected -> {
_uiState.update { it.copy(isConnected = false) }
}
}
}
}

View File

@ -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
)
}
}
}
}
}
}

View File

@ -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)
)
}
}

View File

@ -3,11 +3,9 @@ package org.yobble.messenger.presentation.profile
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
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.filled.CameraAlt
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.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.platform.LocalContext
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.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.UserAvatar
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
@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 {
return try {
val zonedUtc = java.time.ZonedDateTime.parse(isoString)

View File

@ -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
}
}

View File

@ -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")
}
}
}
}
}

View File

@ -8,11 +8,9 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.filled.Add
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.Lock
import androidx.compose.material.icons.filled.PhoneAndroid
@ -22,18 +20,15 @@ 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.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.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.yobble.messenger.data.local.AccountInfo
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.settings.components.AccountItem
import org.yobble.messenger.presentation.settings.components.ProfileSection
import org.yobble.messenger.presentation.settings.components.SettingsMenuItem
@Composable
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
)
}
}

View File

@ -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
)
}
}