[feat/fix]:
- хз чет пофиксил
This commit is contained in:
parent
4f3df5e440
commit
105b0d5bad
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -0,0 +1,81 @@
|
||||
package org.yobble.messenger.presentation.chat.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.yobble.messenger.data.remote.dto.MessageItemDto
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun MessageActionsSheet(msg: MessageItemDto?, currentUserId: String, clipboard: ClipboardManager, onDismiss: () -> Unit, onEdit: (MessageItemDto) -> Unit, onDelete: (MessageItemDto) -> Unit) {
|
||||
if (msg == null) return
|
||||
val isOwn = msg.senderId == currentUserId
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, containerColor = MaterialTheme.colorScheme.surfaceVariant) {
|
||||
Column(Modifier.padding(bottom = 32.dp)) {
|
||||
if (!msg.content.isNullOrBlank()) {
|
||||
ActionRow(Icons.Default.ContentCopy, "Copy") { clipboard.setText(AnnotatedString(msg.content)); onDismiss() }
|
||||
}
|
||||
if (isOwn && !msg.content.isNullOrBlank()) {
|
||||
ActionRow(Icons.Default.Edit, "Edit") { onEdit(msg) }
|
||||
}
|
||||
ActionRow(Icons.Default.Delete, "Delete", MaterialTheme.colorScheme.error) { onDelete(msg) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DeleteMessageDialog(msg: MessageItemDto?, currentUserId: String, deleteForAll: Boolean, onDeleteForAllChange: (Boolean) -> Unit, onConfirm: (MessageItemDto) -> Unit, onDismiss: () -> Unit) {
|
||||
if (msg == null) return
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Delete message?") },
|
||||
text = {
|
||||
if (msg.senderId == currentUserId) {
|
||||
Row(Modifier.fillMaxWidth().clickable { onDeleteForAllChange(!deleteForAll) }, verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(deleteForAll, onDeleteForAllChange)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Delete for everyone")
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onConfirm(msg) }) { Text("Delete", color = MaterialTheme.colorScheme.error) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun EditMessageDialog(msg: MessageItemDto?, text: String, onTextChange: (String) -> Unit, onConfirm: (MessageItemDto) -> Unit, onDismiss: () -> Unit) {
|
||||
if (msg == null) return
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Edit message") },
|
||||
text = {
|
||||
TextField(text, onTextChange, Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp),
|
||||
colors = TextFieldDefaults.colors(focusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedContainerColor = MaterialTheme.colorScheme.surface, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent))
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onConfirm(msg) }, enabled = text.isNotBlank()) { Text("Save") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ActionRow(icon: ImageVector, label: String, color: Color = MaterialTheme.colorScheme.onSurface, onClick: () -> Unit) {
|
||||
Row(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 24.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, null, tint = color, modifier = Modifier.size(22.dp))
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge, color = color)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package org.yobble.messenger.presentation.chat.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.yobble.messenger.data.remote.dto.MessageItemDto
|
||||
import org.yobble.messenger.util.formatUtcToLocalTime
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
internal fun MessageBubble(message: MessageItemDto, isOutgoing: Boolean, onLongClick: () -> Unit = {}) {
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val primaryContainer = MaterialTheme.colorScheme.primaryContainer
|
||||
val textColor = if (isOutgoing) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
|
||||
val timeColor = if (isOutgoing) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val shape = if (isOutgoing) RoundedCornerShape(18.dp, 18.dp, 4.dp, 18.dp) else RoundedCornerShape(18.dp, 18.dp, 18.dp, 4.dp)
|
||||
val time = formatUtcToLocalTime(message.createdAt)
|
||||
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start) {
|
||||
Box(
|
||||
Modifier.widthIn(max = 280.dp).clip(shape)
|
||||
.combinedClickable(onClick = {}, onLongClick = onLongClick)
|
||||
.then(if (isOutgoing) Modifier.background(Brush.linearGradient(listOf(primary, primaryContainer))) else Modifier.background(MaterialTheme.colorScheme.surfaceVariant))
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
) {
|
||||
Column {
|
||||
if (!message.content.isNullOrBlank()) Text(message.content, color = textColor, fontSize = 15.sp)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text("${if (message.isEdited) "edited " else ""}$time", color = timeColor, fontSize = 11.sp, modifier = Modifier.align(Alignment.End))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,126 @@
|
||||
package org.yobble.messenger.presentation.chat.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material.icons.outlined.EmojiEmotions
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun MessageInputBar(
|
||||
text: String,
|
||||
onTextChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
isSending: Boolean,
|
||||
showEmojiPicker: Boolean = false,
|
||||
onToggleEmoji: () -> Unit = {},
|
||||
focusRequester: FocusRequester? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val canSend = text.isNotBlank() && !isSending
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Emoji toggle
|
||||
IconButton(onClick = onToggleEmoji, modifier = Modifier.size(44.dp)) {
|
||||
Icon(
|
||||
if (showEmojiPicker) Icons.Default.Keyboard else Icons.Outlined.EmojiEmotions,
|
||||
contentDescription = "Emoji",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Input field with attach inside
|
||||
TextField(
|
||||
value = text,
|
||||
onValueChange = onTextChange,
|
||||
modifier = Modifier.weight(1f)
|
||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier),
|
||||
placeholder = {
|
||||
Text("Message...", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||
keyboardActions = KeyboardActions(onSend = { onSend() }),
|
||||
maxLines = 4,
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.AttachFile,
|
||||
contentDescription = "Attach",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clickable { /* TODO: attach */ }
|
||||
)
|
||||
},
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
cursorColor = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
// Send
|
||||
IconButton(
|
||||
onClick = onSend,
|
||||
enabled = canSend,
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (canSend)
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
)
|
||||
else
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
MaterialTheme.colorScheme.surfaceVariant,
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send",
|
||||
tint = if (canSend)
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -85,5 +85,5 @@ fun InitialsAvatar(
|
||||
|
||||
fun buildAvatarUrl(userId: String?, fileId: String?): String? {
|
||||
if (userId.isNullOrBlank() || fileId.isNullOrBlank()) return null
|
||||
return "${BuildConfig.BASE_URL}v1/storage/download/avatar/$userId?file_id=$fileId"
|
||||
return "${BuildConfig.BASE_URL}v1/storage/avatar/download/$userId?file_id=$fileId"
|
||||
}
|
||||
|
||||
@ -7,10 +7,7 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,101 @@
|
||||
package org.yobble.messenger.presentation.contacts.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun AddContactDialog(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
isAdding: Boolean
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Add contact") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
label = { Text("Login") },
|
||||
placeholder = { Text("username") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = onAdd,
|
||||
enabled = query.isNotBlank() && !isAdding
|
||||
) {
|
||||
if (isAdding) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text("Add")
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun RenameContactDialog(
|
||||
currentName: String,
|
||||
onNameChange: (String) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
isSaving: Boolean
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Rename contact") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = currentName,
|
||||
onValueChange = onNameChange,
|
||||
label = { Text("Display name") },
|
||||
placeholder = { Text("Custom name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = onSave,
|
||||
enabled = !isSaving
|
||||
) {
|
||||
if (isSaving) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -6,65 +6,48 @@ import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,236 @@
|
||||
package org.yobble.messenger.presentation.main.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Verified
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.yobble.messenger.data.remote.dto.PrivateChatListItemDto
|
||||
import org.yobble.messenger.presentation.common.UserAvatar
|
||||
import org.yobble.messenger.presentation.main.HomeUiState
|
||||
import org.yobble.messenger.util.formatUtcToLocalTime
|
||||
|
||||
@Composable
|
||||
internal fun ChatsContent(
|
||||
uiState: HomeUiState,
|
||||
onNavigateToChat: (String) -> Unit,
|
||||
selectedChatIds: Set<String>,
|
||||
onToggleChatSelection: (String) -> Unit,
|
||||
onLoadMore: () -> Unit,
|
||||
bottomPadding: Dp
|
||||
) {
|
||||
if (uiState.isLoading && uiState.chats.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
} else if (uiState.chats.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
"No chats yet",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
"Start a new conversation",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val hasMore = uiState.hasMore
|
||||
val isLoading = uiState.isLoading
|
||||
val shouldLoadMore by remember(hasMore, isLoading) {
|
||||
derivedStateOf {
|
||||
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||
val totalItems = listState.layoutInfo.totalItemsCount
|
||||
lastVisible >= totalItems - 3 && hasMore && !isLoading
|
||||
}
|
||||
}
|
||||
LaunchedEffect(shouldLoadMore) {
|
||||
if (shouldLoadMore) onLoadMore()
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(bottom = bottomPadding)
|
||||
) {
|
||||
items(uiState.chats, key = { it.chatId }) { chat ->
|
||||
ChatListItem(
|
||||
chat = chat,
|
||||
isSelected = chat.chatId in selectedChatIds,
|
||||
inSelectionMode = selectedChatIds.isNotEmpty(),
|
||||
onClick = {
|
||||
if (selectedChatIds.isNotEmpty()) {
|
||||
onToggleChatSelection(chat.chatId)
|
||||
} else {
|
||||
onNavigateToChat(chat.chatId)
|
||||
}
|
||||
},
|
||||
onLongClick = { onToggleChatSelection(chat.chatId) }
|
||||
)
|
||||
}
|
||||
if (uiState.isLoading && uiState.chats.isNotEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
internal fun ChatListItem(
|
||||
chat: PrivateChatListItemDto,
|
||||
isSelected: Boolean,
|
||||
inSelectionMode: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit
|
||||
) {
|
||||
val chatData = chat.chatData
|
||||
val displayName = chatData?.customName
|
||||
?: chatData?.fullName
|
||||
?: chatData?.login
|
||||
?: chat.chatType.replaceFirstChar { it.uppercase() }
|
||||
val isVerified = chatData?.verification != null
|
||||
val lastMessageText = chat.lastMessage?.content ?: ""
|
||||
val time = formatUtcToLocalTime(chat.lastMessage?.createdAt)
|
||||
|
||||
val bgColor = if (isSelected)
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
||||
else
|
||||
Color.Transparent
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(bgColor)
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
UserAvatar(
|
||||
userId = chatData?.userId,
|
||||
fileId = chatData?.avatars?.current?.fileId,
|
||||
displayName = displayName
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false)
|
||||
)
|
||||
if (isVerified) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
Icons.Default.Verified,
|
||||
contentDescription = "Verified",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = time,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (chat.unreadCount > 0)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = lastMessageText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (chat.unreadCount > 0) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (chat.unreadCount > 99) "99+" else chat.unreadCount.toString(),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,207 @@
|
||||
package org.yobble.messenger.presentation.main.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.filled.Contacts
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.yobble.messenger.BuildConfig
|
||||
import org.yobble.messenger.presentation.accounts.AccountSwitcherUiState
|
||||
import org.yobble.messenger.presentation.common.UserAvatar
|
||||
import org.yobble.messenger.presentation.main.HomeUiState
|
||||
|
||||
internal data class DrawerTab(
|
||||
val label: String,
|
||||
val index: Int
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
internal fun DrawerContent(
|
||||
uiState: HomeUiState,
|
||||
accountsState: AccountSwitcherUiState,
|
||||
selectedTab: Int,
|
||||
tabs: List<DrawerTab>,
|
||||
onAccountSwitched: () -> Unit,
|
||||
onAddAccount: () -> Unit,
|
||||
onSwitchAccount: (String) -> Unit,
|
||||
onPrepareNewAccountLogin: () -> Unit,
|
||||
coroutineScope: CoroutineScope,
|
||||
drawerState: DrawerState,
|
||||
onDrawerSelectedTabChanged: (Int) -> Unit
|
||||
) {
|
||||
var showDrawerAccountPopup by remember { mutableStateOf(false) }
|
||||
|
||||
ModalDrawerSheet(
|
||||
drawerContainerColor = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.widthIn(max = 280.dp)
|
||||
) {
|
||||
// Account header
|
||||
val acc = uiState.activeAccount
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
// Navigate to Settings tab (last tab)
|
||||
onDrawerSelectedTabChanged(tabs.last().index)
|
||||
}
|
||||
},
|
||||
onLongClick = { showDrawerAccountPopup = true }
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 20.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (acc != null) {
|
||||
UserAvatar(
|
||||
userId = acc.userId,
|
||||
fileId = acc.avatarFileId,
|
||||
displayName = acc.displayName ?: acc.login ?: "U",
|
||||
size = 44.dp,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
acc.displayName ?: acc.login ?: "Account",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
if (acc.login != null) {
|
||||
Text(
|
||||
"@${acc.login}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showDrawerAccountPopup,
|
||||
onDismissRequest = { showDrawerAccountPopup = false },
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
accountsState.accounts.forEach { account ->
|
||||
val isActive = account.accountId == accountsState.activeAccountId
|
||||
val name = account.displayName ?: account.login ?: "Account"
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
UserAvatar(account.userId, account.avatarFileId, name, 28.dp, 11.sp)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
name,
|
||||
fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal,
|
||||
color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
showDrawerAccountPopup = false
|
||||
if (!isActive) {
|
||||
onSwitchAccount(account.accountId)
|
||||
onAccountSwitched()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f), modifier = Modifier.padding(vertical = 4.dp))
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.PersonAdd, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(28.dp).padding(2.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text("Add account", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
showDrawerAccountPopup = false
|
||||
onPrepareNewAccountLogin()
|
||||
onAddAccount()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f))
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Chats
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Chats") },
|
||||
selected = selectedTab == tabs[0].index,
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
onDrawerSelectedTabChanged(tabs[0].index)
|
||||
}
|
||||
},
|
||||
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) },
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
colors = NavigationDrawerItemDefaults.colors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
||||
)
|
||||
)
|
||||
|
||||
// Contacts
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Contacts") },
|
||||
selected = selectedTab == tabs[1].index,
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
onDrawerSelectedTabChanged(tabs[1].index)
|
||||
}
|
||||
},
|
||||
icon = { Icon(Icons.Default.Contacts, contentDescription = null) },
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
colors = NavigationDrawerItemDefaults.colors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
||||
)
|
||||
)
|
||||
|
||||
// Settings
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Settings") },
|
||||
selected = selectedTab == tabs[2].index,
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
onDrawerSelectedTabChanged(tabs[2].index)
|
||||
}
|
||||
},
|
||||
icon = { Icon(Icons.Default.Settings, contentDescription = null) },
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
colors = NavigationDrawerItemDefaults.colors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
// Version
|
||||
Text(
|
||||
text = "Yobble v${BuildConfig.VERSION_NAME}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -3,11 +3,9 @@ package org.yobble.messenger.presentation.profile
|
||||
import android.net.Uri
|
||||
import 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)
|
||||
|
||||
@ -0,0 +1,243 @@
|
||||
package org.yobble.messenger.presentation.profile.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.EmojiEvents
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.yobble.messenger.data.remote.dto.AchievementItemDto
|
||||
|
||||
@Composable
|
||||
internal fun AchievementsSection(
|
||||
achievements: Map<String, List<AchievementItemDto>>
|
||||
) {
|
||||
if (achievements.isEmpty()) return
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Achievements",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
achievements.forEach { (category, items) ->
|
||||
AchievementGroup(category = category, items = items)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun AchievementGroup(
|
||||
category: String,
|
||||
items: List<AchievementItemDto>
|
||||
) {
|
||||
// First item = master achievement, rest = sub-achievements
|
||||
val master = items.firstOrNull() ?: return
|
||||
val others = items.drop(1)
|
||||
val completedCount = items.count { it.isCompleted }
|
||||
val masterColor = badgeColor(master.badgeType)
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(14.dp)) {
|
||||
// Category header + progress
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = category,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "$completedCount/${items.size}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
// Master achievement — larger
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(masterColor.copy(alpha = if (master.isCompleted) 0.2f else 0.08f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
if (master.isCompleted) Icons.Default.EmojiEvents else Icons.Default.Lock,
|
||||
contentDescription = null,
|
||||
tint = masterColor.copy(alpha = if (master.isCompleted) 1f else 0.4f),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = master.name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (master.isCompleted) 1f else 0.5f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (!master.description.isNullOrBlank()) {
|
||||
Text(
|
||||
text = master.description,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-achievements — horizontal scroll, single row
|
||||
if (others.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState())
|
||||
) {
|
||||
others.forEach { achievement ->
|
||||
SmallAchievementIcon(achievement)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SmallAchievementIcon(achievement: AchievementItemDto) {
|
||||
val color = badgeColor(achievement.badgeType)
|
||||
val alpha = if (achievement.isCompleted) 1f else 0.35f
|
||||
var showTooltip by remember { mutableStateOf(false) }
|
||||
|
||||
Box {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.width(48.dp)
|
||||
.clickable { showTooltip = true }
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color.copy(alpha = if (achievement.isCompleted) 0.2f else 0.06f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
if (achievement.isCompleted) Icons.Default.EmojiEvents else Icons.Default.Lock,
|
||||
contentDescription = null,
|
||||
tint = color.copy(alpha = alpha),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = achievement.name,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontSize = 8.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showTooltip,
|
||||
onDismissRequest = { showTooltip = false },
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).widthIn(max = 200.dp)) {
|
||||
Text(
|
||||
text = achievement.name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = achievement.badgeType.replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = color
|
||||
)
|
||||
if (!achievement.description.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = achievement.description,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (achievement.requiredProgress != null && achievement.requiredProgress > 0) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
val progress = (achievement.progress ?: 0).toFloat() / achievement.requiredProgress
|
||||
LinearProgressIndicator(
|
||||
progress = { progress.coerceIn(0f, 1f) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.clip(RoundedCornerShape(2.dp)),
|
||||
color = color,
|
||||
trackColor = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
Text(
|
||||
text = "${achievement.progress ?: 0}/${achievement.requiredProgress}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 10.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun badgeColor(badgeType: String): Color {
|
||||
return when (badgeType) {
|
||||
"bronze" -> Color(0xFFCD7F32)
|
||||
"silver" -> Color(0xFF9E9E9E)
|
||||
"gold" -> Color(0xFFFFD700)
|
||||
"platinum" -> Color(0xFF7B8794)
|
||||
"diamond" -> Color(0xFF00BCD4)
|
||||
"legendary" -> Color(0xFFFF6F00)
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
package org.yobble.messenger.presentation.profile.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun EditProfileSection(
|
||||
fullName: String,
|
||||
bio: String,
|
||||
onFullNameChange: (String) -> Unit,
|
||||
onBioChange: (String) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
isSaving: Boolean,
|
||||
canSave: Boolean
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = fullName,
|
||||
onValueChange = onFullNameChange,
|
||||
label = { Text("Full name") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
val bioMaxLength = 1024
|
||||
val bioOverLimit = bio.length > bioMaxLength
|
||||
OutlinedTextField(
|
||||
value = bio,
|
||||
onValueChange = onBioChange,
|
||||
label = { Text("Bio") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
maxLines = 5,
|
||||
isError = bioOverLimit,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
supportingText = {
|
||||
Text(
|
||||
text = "${bio.length}/$bioMaxLength",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.End,
|
||||
color = if (bioOverLimit)
|
||||
MaterialTheme.colorScheme.error
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = !isSaving
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
Button(
|
||||
onClick = onSave,
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = !isSaving && canSave
|
||||
) {
|
||||
if (isSaving) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,11 +8,9 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,191 @@
|
||||
package org.yobble.messenger.presentation.settings.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.yobble.messenger.data.local.AccountInfo
|
||||
import org.yobble.messenger.presentation.common.UserAvatar
|
||||
|
||||
@Composable
|
||||
internal fun ProfileSection(
|
||||
account: AccountInfo,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val displayName = account.displayName ?: account.login ?: "Account"
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
UserAvatar(
|
||||
userId = account.userId,
|
||||
fileId = account.avatarFileId,
|
||||
displayName = displayName,
|
||||
size = 80.dp,
|
||||
fontSize = 32.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
if (account.login != null) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "@${account.login}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
if (!account.bio.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = account.bio,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun AccountItem(
|
||||
account: AccountInfo,
|
||||
onClick: () -> Unit,
|
||||
onRemove: () -> Unit
|
||||
) {
|
||||
val displayName = account.displayName ?: account.login ?: "Account"
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
UserAvatar(
|
||||
userId = account.userId,
|
||||
fileId = account.avatarFileId,
|
||||
displayName = displayName,
|
||||
size = 40.dp,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (account.login != null) {
|
||||
Text(
|
||||
text = "@${account.login}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = onRemove, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SettingsMenuItem(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit,
|
||||
showDivider: Boolean = true
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
if (showDivider) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f),
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user