From 105b0d5bad45c3b684309c84da50595df3dd9124 Mon Sep 17 00:00:00 2001 From: YaAndreyIgorevich Date: Wed, 15 Apr 2026 05:24:28 +0700 Subject: [PATCH] =?UTF-8?q?[feat/fix]:=20=20=20-=20=D1=85=D0=B7=20=D1=87?= =?UTF-8?q?=D0=B5=D1=82=20=D0=BF=D0=BE=D1=84=D0=B8=D0=BA=D1=81=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messenger/data/remote/api/StorageApi.kt | 2 +- .../messenger/presentation/chat/ChatScreen.kt | 226 +-------- .../chat/components/ChatDialogs.kt | 81 ++++ .../chat/components/MessageBubble.kt | 44 ++ .../chat/components/MessageInputBar.kt | 126 +++++ .../presentation/common/UserAvatar.kt | 2 +- .../presentation/contacts/ContactsScreen.kt | 97 +--- .../contacts/components/ContactDialogs.kt | 101 ++++ .../messenger/presentation/main/HomeScreen.kt | 448 ++---------------- .../presentation/main/HomeViewModel.kt | 12 +- .../main/components/ChatListItem.kt | 236 +++++++++ .../main/components/DrawerContent.kt | 207 ++++++++ .../presentation/profile/ProfileScreen.kt | 314 +----------- .../profile/components/AchievementsSection.kt | 243 ++++++++++ .../profile/components/EditProfileSection.kt | 92 ++++ .../presentation/settings/SettingsScreen.kt | 181 +------ .../settings/components/SettingsComponents.kt | 191 ++++++++ 17 files changed, 1391 insertions(+), 1212 deletions(-) create mode 100644 app/src/main/java/org/yobble/messenger/presentation/chat/components/ChatDialogs.kt create mode 100644 app/src/main/java/org/yobble/messenger/presentation/chat/components/MessageBubble.kt create mode 100644 app/src/main/java/org/yobble/messenger/presentation/chat/components/MessageInputBar.kt create mode 100644 app/src/main/java/org/yobble/messenger/presentation/contacts/components/ContactDialogs.kt create mode 100644 app/src/main/java/org/yobble/messenger/presentation/main/components/ChatListItem.kt create mode 100644 app/src/main/java/org/yobble/messenger/presentation/main/components/DrawerContent.kt create mode 100644 app/src/main/java/org/yobble/messenger/presentation/profile/components/AchievementsSection.kt create mode 100644 app/src/main/java/org/yobble/messenger/presentation/profile/components/EditProfileSection.kt create mode 100644 app/src/main/java/org/yobble/messenger/presentation/settings/components/SettingsComponents.kt diff --git a/app/src/main/java/org/yobble/messenger/data/remote/api/StorageApi.kt b/app/src/main/java/org/yobble/messenger/data/remote/api/StorageApi.kt index 53879a9..495398d 100644 --- a/app/src/main/java/org/yobble/messenger/data/remote/api/StorageApi.kt +++ b/app/src/main/java/org/yobble/messenger/data/remote/api/StorageApi.kt @@ -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 diff --git a/app/src/main/java/org/yobble/messenger/presentation/chat/ChatScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/chat/ChatScreen.kt index ac80bb8..02fc21f 100644 --- a/app/src/main/java/org/yobble/messenger/presentation/chat/ChatScreen.kt +++ b/app/src/main/java/org/yobble/messenger/presentation/chat/ChatScreen.kt @@ -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 diff --git a/app/src/main/java/org/yobble/messenger/presentation/chat/components/ChatDialogs.kt b/app/src/main/java/org/yobble/messenger/presentation/chat/components/ChatDialogs.kt new file mode 100644 index 0000000..217dbbd --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/chat/components/ChatDialogs.kt @@ -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) + } +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/chat/components/MessageBubble.kt b/app/src/main/java/org/yobble/messenger/presentation/chat/components/MessageBubble.kt new file mode 100644 index 0000000..566ffa1 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/chat/components/MessageBubble.kt @@ -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)) + } + } + } +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/chat/components/MessageInputBar.kt b/app/src/main/java/org/yobble/messenger/presentation/chat/components/MessageInputBar.kt new file mode 100644 index 0000000..68cbe8e --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/chat/components/MessageInputBar.kt @@ -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) + ) + } + } +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/common/UserAvatar.kt b/app/src/main/java/org/yobble/messenger/presentation/common/UserAvatar.kt index 51080d9..59c4420 100644 --- a/app/src/main/java/org/yobble/messenger/presentation/common/UserAvatar.kt +++ b/app/src/main/java/org/yobble/messenger/presentation/common/UserAvatar.kt @@ -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" } diff --git a/app/src/main/java/org/yobble/messenger/presentation/contacts/ContactsScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/contacts/ContactsScreen.kt index fcdcded..a6fb1dd 100644 --- a/app/src/main/java/org/yobble/messenger/presentation/contacts/ContactsScreen.kt +++ b/app/src/main/java/org/yobble/messenger/presentation/contacts/ContactsScreen.kt @@ -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") - } - } - ) -} diff --git a/app/src/main/java/org/yobble/messenger/presentation/contacts/components/ContactDialogs.kt b/app/src/main/java/org/yobble/messenger/presentation/contacts/components/ContactDialogs.kt new file mode 100644 index 0000000..42d5ebc --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/contacts/components/ContactDialogs.kt @@ -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") + } + } + ) +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/main/HomeScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/main/HomeScreen.kt index f0cf09d..35388b2 100644 --- a/app/src/main/java/org/yobble/messenger/presentation/main/HomeScreen.kt +++ b/app/src/main/java/org/yobble/messenger/presentation/main/HomeScreen.kt @@ -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 } - ) - .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) - ) - } + 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 } + ) } val scaffoldContent: @Composable () -> Unit = { @@ -378,10 +216,18 @@ fun HomeScreen( } else { TopAppBar( title = { - Text( - tabs[selectedTab].label, - fontWeight = FontWeight.Bold - ) + 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, - 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 - ) - } - } - } - } - } - -} diff --git a/app/src/main/java/org/yobble/messenger/presentation/main/HomeViewModel.kt b/app/src/main/java/org/yobble/messenger/presentation/main/HomeViewModel.kt index f68c11a..6c4b793 100644 --- a/app/src/main/java/org/yobble/messenger/presentation/main/HomeViewModel.kt +++ b/app/src/main/java/org/yobble/messenger/presentation/main/HomeViewModel.kt @@ -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) } + } } } } diff --git a/app/src/main/java/org/yobble/messenger/presentation/main/components/ChatListItem.kt b/app/src/main/java/org/yobble/messenger/presentation/main/components/ChatListItem.kt new file mode 100644 index 0000000..c6eb661 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/main/components/ChatListItem.kt @@ -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, + 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 + ) + } + } + } + } + } +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/main/components/DrawerContent.kt b/app/src/main/java/org/yobble/messenger/presentation/main/components/DrawerContent.kt new file mode 100644 index 0000000..e0cd1d7 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/main/components/DrawerContent.kt @@ -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, + 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) + ) + } +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/profile/ProfileScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/profile/ProfileScreen.kt index c0ed57d..a206e8a 100644 --- a/app/src/main/java/org/yobble/messenger/presentation/profile/ProfileScreen.kt +++ b/app/src/main/java/org/yobble/messenger/presentation/profile/ProfileScreen.kt @@ -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> -) { - 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 -) { - // 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) diff --git a/app/src/main/java/org/yobble/messenger/presentation/profile/components/AchievementsSection.kt b/app/src/main/java/org/yobble/messenger/presentation/profile/components/AchievementsSection.kt new file mode 100644 index 0000000..44dfd09 --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/profile/components/AchievementsSection.kt @@ -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> +) { + 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 +) { + // 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 + } +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/profile/components/EditProfileSection.kt b/app/src/main/java/org/yobble/messenger/presentation/profile/components/EditProfileSection.kt new file mode 100644 index 0000000..9a43ada --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/profile/components/EditProfileSection.kt @@ -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") + } + } + } + } +} diff --git a/app/src/main/java/org/yobble/messenger/presentation/settings/SettingsScreen.kt b/app/src/main/java/org/yobble/messenger/presentation/settings/SettingsScreen.kt index a276ad6..35e2a23 100644 --- a/app/src/main/java/org/yobble/messenger/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/org/yobble/messenger/presentation/settings/SettingsScreen.kt @@ -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 - ) - } -} diff --git a/app/src/main/java/org/yobble/messenger/presentation/settings/components/SettingsComponents.kt b/app/src/main/java/org/yobble/messenger/presentation/settings/components/SettingsComponents.kt new file mode 100644 index 0000000..e2512bf --- /dev/null +++ b/app/src/main/java/org/yobble/messenger/presentation/settings/components/SettingsComponents.kt @@ -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 + ) + } +}