From e92578c36486503d5e42f18e247f26ea4ec7453a Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 3 Feb 2026 14:30:39 +0300 Subject: [PATCH] Start implementing the UI Signed-off-by: denis0001-dev --- .../commonMain/kotlin/ru/fromchat/ui/App.kt | 19 ++ .../kotlin/ru/fromchat/ui/dm/DmScreen.kt | 276 ++++++++++++++++++ .../kotlin/ru/fromchat/ui/main/SettingsTab.kt | 25 ++ .../ru/fromchat/ui/profile/ProfileScreen.kt | 217 ++++++++++++++ 4 files changed, 537 insertions(+) create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmScreen.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index 2b352c7..2175d05 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -28,7 +28,9 @@ import ru.fromchat.ui.auth.LoginScreen import ru.fromchat.ui.auth.RegisterScreen import ru.fromchat.ui.chat.PublicChatScreen import ru.fromchat.ui.debug.DebugApiScreen +import ru.fromchat.ui.dm.DmScreen import ru.fromchat.ui.main.MainScreen +import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.ui.setup.ServerConfigScreen val LocalNavController = compositionLocalOf { error("NavController not provided") } @@ -174,6 +176,23 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) { DebugApiScreen() } + composable("profile/{userId}") { backStackEntry -> + val userId = backStackEntry.arguments?.getString("userId")?.toIntOrNull() + ProfileScreen( + userId = userId, + onBack = { navController.navigateUp() }, + onChat = { navController.navigate("dm/$it") } + ) + } + + composable("dm/{otherUserId}") { backStackEntry -> + val otherUserId = backStackEntry.arguments?.getString("otherUserId")?.toIntOrNull() ?: 0 + DmScreen( + otherUserId = otherUserId, + onBack = { navController.navigateUp() } + ) + } + composable("about") { AboutScreen() } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmScreen.kt new file mode 100644 index 0000000..befd114 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmScreen.kt @@ -0,0 +1,276 @@ +package ru.fromchat.ui.dm + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.serializer +import ru.fromchat.api.ApiClient +import ru.fromchat.api.DmEnvelope +import ru.fromchat.api.WebSocketManager +import ru.fromchat.api.WebSocketMessage +import ru.fromchat.crypto.decryptEnvelope + +private data class DecryptedDmMessage( + val id: Int, + val text: String, + val timestamp: String, + val isOutgoing: Boolean +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DmScreen( + otherUserId: Int, + onBack: () -> Unit +) { + val coroutineScope = rememberCoroutineScope() + val messages = remember { mutableStateListOf() } + var isLoading by remember { mutableStateOf(false) } + var status by remember { mutableStateOf(null) } + var input by remember { mutableStateOf("") } + + val currentUserId = ApiClient.user?.id + val json = Json { ignoreUnknownKeys = true } + + suspend fun loadHistory() { + isLoading = true + runCatching { + ApiClient.getDmHistory(otherUserId) + }.onSuccess { response -> + val decrypted = response.messages.mapNotNull { envelope -> + val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() + plaintext?.let { + DecryptedDmMessage( + id = envelope.id, + text = it, + timestamp = envelope.timestamp, + isOutgoing = currentUserId != null && envelope.senderId == currentUserId + ) + } + } + messages.clear() + messages.addAll(decrypted) + status = null + }.onFailure { + status = it.message ?: "Failed to load DMs" + } + isLoading = false + } + + LaunchedEffect(otherUserId) { + loadHistory() + } + + DisposableEffect(otherUserId) { + val handler: (WebSocketMessage) -> Unit = handler@ { msg -> + val payloads = extractDmPayloads(msg) + for (payload in payloads) { + val envelope = runCatching { + json.decodeFromJsonElement(DmEnvelope.serializer(), payload) + }.getOrNull() ?: continue + if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) continue + coroutineScope.launch { + val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() + if (plaintext == null) return@launch + val newMessage = DecryptedDmMessage( + id = envelope.id, + text = plaintext, + timestamp = envelope.timestamp, + isOutgoing = currentUserId != null && envelope.senderId == currentUserId + ) + if (messages.none { it.id == newMessage.id }) { + messages.add(newMessage) + } + } + } + } + WebSocketManager.addGlobalMessageHandler(handler) + onDispose { + WebSocketManager.removeGlobalMessageHandler(handler) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = "DM with user $otherUserId") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(imageVector = Icons.Filled.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (messages.isEmpty()) { + item { + Text( + text = "No messages yet.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } else { + items(messages, key = { it.id }) { message -> + MessageBubble(message = message) + } + } + } + } + + status?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + + Divider() + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Bottom + ) { + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.weight(1f), + label = { Text("Message") }, + singleLine = false + ) + Button( + onClick = { + if (input.isBlank()) return@Button + coroutineScope.launch { + runCatching { + ApiClient.sendDm(recipientId = otherUserId, plaintext = input.trim()) + }.onSuccess { + input = "" + loadHistory() + }.onFailure { + status = it.message ?: "Failed to send DM" + } + } + } + ) { + Text(text = "Send") + } + } + } + } +} + +@Composable +private fun MessageBubble(message: DecryptedDmMessage) { + val alignment = if (message.isOutgoing) Alignment.End else Alignment.Start + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = alignment + ) { + Card( + modifier = Modifier + .fillMaxWidth(0.85f), + shape = MaterialTheme.shapes.medium + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text(text = message.text, style = MaterialTheme.typography.bodyMedium) + Spacer(modifier = Modifier.size(4.dp)) + Text( + text = message.timestamp, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +private fun extractDmPayloads(msg: WebSocketMessage): List { + val results = mutableListOf() + when (msg.type) { + "dmNew" -> msg.data?.let { results.add(it) } + "updates" -> { + val updatesArray = msg.data + ?.jsonObject + ?.get("updates") + ?.jsonArray + ?: JsonArray(emptyList()) + for (item in updatesArray) { + val obj = item.jsonObject + if (obj["type"]?.jsonPrimitive?.content == "dmNew") { + obj["data"]?.let { results.add(it) } + } + } + } + } + return results +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt index 833a3cd..c215d45 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt @@ -214,6 +214,31 @@ fun SettingsTab( } ) + val currentUserId = ApiClient.user?.id ?: 0 + ListItem( + headline = "Profile screen", + supportingText = "Show your profile with chat/link actions", + onClick = { + if (currentUserId != 0) { + navController.navigate("profile/$currentUserId") + } + }, + divider = true, + dividerColor = MaterialTheme.colorScheme.surface, + dividerThickness = 2.dp + ) + + ListItem( + headline = "DM screen (user 2)", + supportingText = "Open DM flow with another user", + onClick = { + navController.navigate("dm/2") + }, + divider = true, + dividerColor = MaterialTheme.colorScheme.surface, + dividerThickness = 2.dp + ) + ListItem( headline = stringResource(Res.string.logout), leadingContent = { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt new file mode 100644 index 0000000..2639005 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt @@ -0,0 +1,217 @@ +package ru.fromchat.ui.profile + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import ru.fromchat.api.ApiClient +import ru.fromchat.api.UserProfile + +private data class ProfileUiState( + val profile: UserProfile? = null, + val isLoading: Boolean = true, + val error: String? = null, + val linkStatus: String? = null +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfileScreen( + userId: Int?, + onBack: () -> Unit, + onChat: (Int) -> Unit +) { + val clipboardManager: ClipboardManager = LocalClipboardManager.current + var state by remember { mutableStateOf(ProfileUiState()) } + + val targetUserId = userId.takeIf { it != null && it > 0 } + val fetchKey = targetUserId ?: 0 + + LaunchedEffect(fetchKey) { + state = state.copy(isLoading = true, error = null) + runCatching { + if (targetUserId == null) { + ApiClient.getOwnProfile() + } else { + ApiClient.getProfileById(targetUserId) + } + }.onSuccess { profile -> + state = state.copy(profile = profile, isLoading = false) + }.onFailure { + state = state.copy(error = it.message ?: "Unable to load profile", isLoading = false) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = "Profile") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(imageVector = Icons.Filled.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp) + ) { + val errorMessage = state.error + when { + state.isLoading -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + errorMessage != null -> { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.align(Alignment.Center) + ) + } + state.profile != null -> { + val profile = state.profile!! + val profileLink = profile.username.takeIf { it.isNotBlank() } + ?.let { "https://fromchat.ru/@$it" } + ?: "https://fromchat.ru/?u=${profile.id}" + val displayName = profile.displayName?.takeIf { it.isNotBlank() } ?: profile.username + val initials = profile.displayName?.firstOrNull() + ?: profile.username.firstOrNull() + ?: '?' + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top + ) { + Surface( + modifier = Modifier + .size(128.dp) + .padding(top = 16.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = initials.toString(), + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = displayName, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "@${profile.username}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + onClick = { onChat(profile.id) }, + modifier = Modifier.weight(1f) + ) { + Text(text = "Chat") + } + Button( + onClick = { + clipboardManager.setText(AnnotatedString(profileLink)) + state = state.copy(linkStatus = "Link copied!") + }, + modifier = Modifier.weight(1f) + ) { + Text(text = "Link") + } + } + + state.linkStatus?.let { + Spacer(modifier = Modifier.height(8.dp)) + Text(text = it, style = MaterialTheme.typography.bodySmall) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + ProfileField(label = "Username", value = profile.username) + Divider(modifier = Modifier.padding(vertical = 8.dp)) + ProfileField(label = "Bio", value = profile.bio ?: "No bio") + Divider(modifier = Modifier.padding(vertical = 8.dp)) + ProfileField(label = "Member since", value = profile.createdAt ?: "Unknown") + Divider(modifier = Modifier.padding(vertical = 8.dp)) + ProfileField( + label = "Status", + value = if ((profile.deleted == true) || (profile.suspended == true)) "Suspended/Deleted" else "Active" + ) + } + } + } + } + } + } + } +} + +@Composable +private fun ProfileField(label: String, value: String) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium + ) + } +}