[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 { 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>

View File

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

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? { 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"
} }

View File

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

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

View File

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

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

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

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