mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Improve UI, add search bar
This commit is contained in:
Generated
+1
-3
@@ -1,8 +1,6 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="shared">
|
||||
<output-path>$PROJECT_DIR$/utils/shared/build/libs</output-path>
|
||||
<root id="archive" name="shared.jar">
|
||||
<element id="module-output" name="FromChat.utils.shared.androidMain" />
|
||||
</root>
|
||||
<root id="archive" name="shared.jar" />
|
||||
</artifact>
|
||||
</component>
|
||||
+2
@@ -34,9 +34,11 @@
|
||||
<inspection_tool class="LocalVariableName" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="MultiplatformPreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="MultiplatformPreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="NestedLambdaShadowedImplicitParameter" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
|
||||
@@ -34,6 +34,10 @@
|
||||
<string name="contacts_empty_body">Список контактов появится здесь, когда функция будет готова.</string>
|
||||
<string name="public_chat">Общий чат</string>
|
||||
<string name="chat_last_mesaage">Вы: последнее сообщение</string>
|
||||
<string name="search_hint">Найдите пользователя, имя или чат</string>
|
||||
<string name="search_title">Поиск</string>
|
||||
<string name="search_not_found">Ничего не найдено</string>
|
||||
<string name="search_not_found_message">Ничего не найдено. Попробуйте переформулировать запрос.</string>
|
||||
<string name="message_placeholder">Напишите сообщение…</string>
|
||||
<string name="status_connecting">Соединение</string>
|
||||
<string name="status_updating">Обновление</string>
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
<string name="contacts_empty_body">Your contacts will appear here when this feature is ready.</string>
|
||||
<string name="public_chat">Main chat</string>
|
||||
<string name="chat_last_mesaage">You: last message</string>
|
||||
<string name="search_hint">Search by name, username or chat</string>
|
||||
<string name="search_title">Search</string>
|
||||
<string name="search_not_found">No results</string>
|
||||
<string name="search_not_found_message">Nothing found here. Try rephrasing your query.</string>
|
||||
<string name="message_placeholder">Write a message…</string>
|
||||
|
||||
<!-- Connection / chat chrome -->
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package ru.fromchat
|
||||
|
||||
import kotlin.collections.HashMap
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.InternalResourceApi
|
||||
|
||||
@OptIn(InternalResourceApi::class)
|
||||
private fun collectCommonMainStringResources(): HashMap<String, StringResource> {
|
||||
val map = HashMap<String, StringResource>()
|
||||
_collectCommonMainString0Resources(map)
|
||||
_collectCommonMainString1Resources(map)
|
||||
return map
|
||||
}
|
||||
|
||||
internal fun resolveSearchHintResource(): StringResource {
|
||||
val map = collectCommonMainStringResources()
|
||||
return map["search_hint"]
|
||||
?: map["app_name"]
|
||||
?: error("Search hint resource is missing")
|
||||
}
|
||||
|
||||
internal fun resolveSearchNotFoundTitleResource(): StringResource {
|
||||
val map = collectCommonMainStringResources()
|
||||
return map["search_not_found"]
|
||||
?: map["search_not_found_message"]
|
||||
?: map["app_name"]
|
||||
?: error("Search not-found title resource is missing")
|
||||
}
|
||||
|
||||
internal fun resolveSearchNotFoundMessageResource(): StringResource {
|
||||
val map = collectCommonMainStringResources()
|
||||
return map["search_not_found_message"]
|
||||
?: map["search_not_found"]
|
||||
?: map["app_name"]
|
||||
?: error("Search not-found message resource is missing")
|
||||
}
|
||||
|
||||
internal fun resolveSearchTitleResource(): StringResource {
|
||||
val map = collectCommonMainStringResources()
|
||||
return map["search_title"]
|
||||
?: map["search_hint"]
|
||||
?: map["app_name"]
|
||||
?: error("Search title resource is missing")
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import kotlinx.coroutines.flow.update
|
||||
|
||||
data class UserStatus(
|
||||
val online: Boolean,
|
||||
val lastSeen: String? = null
|
||||
val lastSeen: String? = null,
|
||||
val typingUsernames: List<String> = emptyList()
|
||||
)
|
||||
|
||||
object UserStatusStore {
|
||||
@@ -15,6 +16,29 @@ object UserStatusStore {
|
||||
val status: StateFlow<Map<Int, UserStatus>> = _status.asStateFlow()
|
||||
|
||||
fun update(userId: Int, online: Boolean, lastSeen: String?) {
|
||||
_status.update { it + (userId to UserStatus(online, lastSeen)) }
|
||||
_status.update { current ->
|
||||
val existing = current[userId] ?: UserStatus(online = false)
|
||||
current + (userId to existing.copy(online = online, lastSeen = lastSeen ?: existing.lastSeen))
|
||||
}
|
||||
}
|
||||
|
||||
fun addTyping(userId: Int, username: String) {
|
||||
val trimmed = username.trim().takeIf { it.isNotBlank() } ?: return
|
||||
_status.update { current ->
|
||||
val existing = current[userId] ?: UserStatus(online = false)
|
||||
val typing = (existing.typingUsernames + trimmed)
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
if (typing == existing.typingUsernames) current else current + (userId to existing.copy(typingUsernames = typing))
|
||||
}
|
||||
}
|
||||
|
||||
fun removeTyping(userId: Int, username: String) {
|
||||
val trimmed = username.trim().takeIf { it.isNotBlank() } ?: return
|
||||
_status.update { current ->
|
||||
val existing = current[userId] ?: return@update current
|
||||
val typing = existing.typingUsernames.filterNot { it.equals(trimmed, ignoreCase = true) }
|
||||
current + (userId to existing.copy(typingUsernames = typing))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.BugReport
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -44,6 +45,8 @@ import ru.fromchat.about_link_website
|
||||
import ru.fromchat.about_version
|
||||
import ru.fromchat.app_desc
|
||||
import ru.fromchat.app_name
|
||||
import ru.fromchat.debug_tools
|
||||
import ru.fromchat.debug_tools_d
|
||||
import ru.fromchat.back
|
||||
import ru.fromchat.ui.branding.FromChatBrandTitle
|
||||
|
||||
@@ -172,6 +175,19 @@ fun AboutScreen() {
|
||||
)
|
||||
}
|
||||
)
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.debug_tools),
|
||||
supportingText = stringResource(Res.string.debug_tools_d),
|
||||
onClick = { navController.navigate("debug") },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.BugReport,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,19 +31,24 @@ import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.UserStatusStore
|
||||
import ru.fromchat.api.ProfileCache
|
||||
import ru.fromchat.api.UpdateSyncManager
|
||||
import ru.fromchat.api.WebSocketManager
|
||||
import ru.fromchat.api.WebSocketMessage
|
||||
import ru.fromchat.api.WebSocketUpdatesData
|
||||
import ru.fromchat.net.NetworkConnectivity
|
||||
import ru.fromchat.core.config.Config
|
||||
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.debug.DebugSearchBarDocsScreen
|
||||
import ru.fromchat.ui.dm.DmChatRoute
|
||||
import ru.fromchat.ui.dm.DmNav
|
||||
import ru.fromchat.ui.dm.DmProfileRoute
|
||||
import ru.fromchat.ui.main.MainScreen
|
||||
import ru.fromchat.ui.main.ChatsSearchScreen
|
||||
import ru.fromchat.ui.profile.ProfileScreen
|
||||
import ru.fromchat.ui.setup.ServerConfigScreen
|
||||
import ru.fromchat.ui.LocalSystemBarsVisibility
|
||||
@@ -62,9 +67,38 @@ import ru.fromchat.ui.main.settings.SettingsRoutes
|
||||
import ru.fromchat.ui.main.settings.SettingsSecurityHubScreen
|
||||
import ru.fromchat.ui.main.settings.SettingsSecurityPasswordFlowScreen
|
||||
import ru.fromchat.ui.main.settings.SettingsServerToolsScreen
|
||||
import kotlinx.serialization.json.*
|
||||
|
||||
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
|
||||
|
||||
private fun handlePresenceStatus(data: JsonObject?) {
|
||||
val userId = data?.get("userId")?.jsonPrimitive?.content?.toIntOrNull() ?: return
|
||||
val online = data["online"]?.jsonPrimitive?.booleanOrNull == true
|
||||
val lastSeen = data["lastSeen"]?.jsonPrimitive?.content
|
||||
UserStatusStore.update(userId, online, lastSeen)
|
||||
}
|
||||
|
||||
private fun handlePresenceTyping(type: String, data: JsonObject?) {
|
||||
val userId = data?.get("userId")?.jsonPrimitive?.content?.toIntOrNull() ?: return
|
||||
val username = data["username"]?.jsonPrimitive?.contentOrNull ?: return
|
||||
when (type) {
|
||||
"dmTyping" -> UserStatusStore.addTyping(userId, username)
|
||||
"stopDmTyping" -> UserStatusStore.removeTyping(userId, username)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePresenceEvent(message: WebSocketMessage) {
|
||||
when (message.type) {
|
||||
"statusUpdate" -> message.data?.jsonObject?.let(::handlePresenceStatus)
|
||||
"dmTyping", "stopDmTyping" -> message.data?.jsonObject?.let { handlePresenceTyping(message.type, it) }
|
||||
"updates" -> {
|
||||
val data = message.data ?: return
|
||||
val updates = ApiClient.json.decodeFromJsonElement<WebSocketUpdatesData>(data)
|
||||
updates.updates.forEach(::handlePresenceEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun NavGraphBuilder.settingsSlideComposable(
|
||||
route: String,
|
||||
animationSpec: FiniteAnimationSpec<IntOffset>,
|
||||
@@ -157,6 +191,16 @@ fun App(
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val handler: (WebSocketMessage) -> Unit = { message ->
|
||||
runCatching { handlePresenceEvent(message) }
|
||||
}
|
||||
WebSocketManager.addGlobalMessageHandler(handler)
|
||||
onDispose {
|
||||
WebSocketManager.removeGlobalMessageHandler(handler)
|
||||
}
|
||||
}
|
||||
|
||||
FromChatTheme {
|
||||
SharedTransitionLayout {
|
||||
val navController = rememberNavController()
|
||||
@@ -254,7 +298,10 @@ fun App(
|
||||
}
|
||||
|
||||
composable("chat") {
|
||||
MainScreen()
|
||||
MainScreen(
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
animatedVisibilityScope = this
|
||||
)
|
||||
}
|
||||
|
||||
composable("chats/publicChat") {
|
||||
@@ -265,9 +312,37 @@ fun App(
|
||||
)
|
||||
}
|
||||
|
||||
val searchScreenFade = tween<Float>(260)
|
||||
composable(
|
||||
route = "search/conversations",
|
||||
enterTransition = { fadeIn(animationSpec = searchScreenFade) },
|
||||
exitTransition = { fadeOut(animationSpec = searchScreenFade) },
|
||||
popEnterTransition = { fadeIn(animationSpec = searchScreenFade) },
|
||||
popExitTransition = { fadeOut(animationSpec = searchScreenFade) }
|
||||
) {
|
||||
ChatsSearchScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
animatedVisibilityScope = this,
|
||||
onOpenProfile = { userId: Int ->
|
||||
if (userId != 0) {
|
||||
navController.navigate("profile/$userId")
|
||||
}
|
||||
},
|
||||
onOpenConversation = { userId: Int ->
|
||||
if (userId != 0) {
|
||||
navController.navigate(DmNav.chatRoute(userId))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable("debug") {
|
||||
DebugApiScreen()
|
||||
}
|
||||
composable("debug/searchbar-docs") {
|
||||
DebugSearchBarDocsScreen()
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "profile/{userId}?useSharedElement={useSharedElement}&sourceMessageId={sourceMessageId}",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package ru.fromchat.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
internal const val SearchBarSharedElement = "search_bar_shared_element"
|
||||
|
||||
@Composable
|
||||
fun M3SearchBar(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onSearch: () -> Unit,
|
||||
placeholder: String,
|
||||
modifier: Modifier = Modifier,
|
||||
shape: Shape = CircleShape,
|
||||
readOnly: Boolean = false,
|
||||
onReadOnlyActivate: (() -> Unit)? = null,
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||
sharedElementKey: Any? = null,
|
||||
autoFocus: Boolean = false,
|
||||
leadingIcon: @Composable () -> Unit = {},
|
||||
trailingIcon: @Composable () -> Unit = {},
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(autoFocus, readOnly) {
|
||||
if (autoFocus && !readOnly) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val onReadOnlyActivateOrNull = onReadOnlyActivate
|
||||
val sharedTransitionModifier = if (
|
||||
sharedTransitionScope != null &&
|
||||
animatedVisibilityScope != null &&
|
||||
sharedElementKey != null
|
||||
) {
|
||||
with(sharedTransitionScope) {
|
||||
Modifier.sharedElement(
|
||||
rememberSharedContentState(key = sharedElementKey),
|
||||
animatedVisibilityScope = animatedVisibilityScope
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
val containerModifier = if (readOnly && onReadOnlyActivateOrNull != null) {
|
||||
modifier.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = onReadOnlyActivateOrNull
|
||||
)
|
||||
} else {
|
||||
modifier
|
||||
}
|
||||
val finalModifier = containerModifier
|
||||
.then(sharedTransitionModifier)
|
||||
.height(56.dp)
|
||||
|
||||
val typography = MaterialTheme.typography.bodyLarge
|
||||
Surface(
|
||||
modifier = finalModifier,
|
||||
shape = shape,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.padding(horizontal = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(modifier = Modifier.size(24.dp)) {
|
||||
leadingIcon()
|
||||
}
|
||||
|
||||
BasicTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
singleLine = true,
|
||||
textStyle = typography.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
enabled = !readOnly,
|
||||
readOnly = readOnly,
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSearch = { onSearch() }
|
||||
),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 8.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
if (query.isEmpty()) {
|
||||
Text(
|
||||
text = placeholder,
|
||||
style = typography.copy(color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 4.dp)
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
|
||||
Box(modifier = Modifier.width(6.dp))
|
||||
Box(modifier = Modifier.size(24.dp)) {
|
||||
trailingIcon()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,8 @@ private object DebugScreenText {
|
||||
const val OpenProfileDesc = "Your profile with chat and link actions"
|
||||
const val OpenDmUser2 = "DM (user 2)"
|
||||
const val OpenDmUser2Desc = "Open a direct message with user ID 2"
|
||||
const val SearchBarDocs = "SearchBar docs"
|
||||
const val SearchBarDocsDesc = "Official Material3 SearchBar snippet in an isolated screen"
|
||||
const val BugIconCd = "Debug"
|
||||
const val StatusPlaceholder = "Status will appear here"
|
||||
const val UnknownError = "Something went wrong. Please try again."
|
||||
@@ -177,6 +179,24 @@ fun DebugApiScreenContent() {
|
||||
Text(text = DebugScreenText.OpenAction)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = DebugScreenText.SearchBarDocs,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = DebugScreenText.SearchBarDocsDesc,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Button(
|
||||
onClick = { navController.navigate("debug/searchbar-docs") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = DebugScreenText.OpenAction)
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
thickness = DividerDefaults.Thickness,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package ru.fromchat.ui.debug
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
import ru.fromchat.ui.M3SearchBar
|
||||
|
||||
@Composable
|
||||
fun DebugSearchBarDocsScreen() {
|
||||
SearchBarExamples()
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun SearchBarExamples() {
|
||||
val allItems = remember {
|
||||
listOf(
|
||||
"Cupcake",
|
||||
"Donut",
|
||||
"Eclair",
|
||||
"Froyo",
|
||||
"Honeycomb",
|
||||
"Ice Cream Sandwich",
|
||||
"Jelly Bean",
|
||||
"KitKat",
|
||||
"Lollipop",
|
||||
"Marshmallow",
|
||||
"Nougat",
|
||||
"Oreo",
|
||||
"Pie"
|
||||
)
|
||||
}
|
||||
|
||||
var query by remember { mutableStateOf("") }
|
||||
val filtered by remember {
|
||||
derivedStateOf {
|
||||
val searchText = query.trim().lowercase()
|
||||
if (searchText.isEmpty()) {
|
||||
allItems
|
||||
} else {
|
||||
allItems.filter { it.lowercase().contains(searchText) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
M3SearchBar(
|
||||
query = query,
|
||||
onQueryChange = { query = it },
|
||||
onSearch = {},
|
||||
placeholder = "Search",
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (query.isNotBlank()) {
|
||||
IconButton(onClick = { query = "" }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Clear,
|
||||
contentDescription = "Clear"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Text("Results")
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(bottom = 16.dp)
|
||||
) {
|
||||
items(filtered) { item ->
|
||||
Text(
|
||||
text = item,
|
||||
modifier = Modifier.padding(vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package ru.fromchat.ui.main
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pr0gramm3r101.utils.resetFocus
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.ProfileCache
|
||||
import ru.fromchat.api.UserStatusStore
|
||||
import ru.fromchat.api.db.CachedConversation
|
||||
import ru.fromchat.api.db.MessageCacheStore
|
||||
import ru.fromchat.chat_last_mesaage
|
||||
import ru.fromchat.resolveSearchHintResource
|
||||
import ru.fromchat.resolveSearchTitleResource
|
||||
import ru.fromchat.resolveSearchNotFoundMessageResource
|
||||
import ru.fromchat.resolveSearchNotFoundTitleResource
|
||||
import ru.fromchat.ui.BackHandler
|
||||
import ru.fromchat.ui.M3SearchBar
|
||||
import ru.fromchat.ui.SearchBarSharedElement
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ChatsSearchScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenProfile: (Int) -> Unit,
|
||||
onOpenConversation: (Int) -> Unit,
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||
) {
|
||||
var searchText by remember { mutableStateOf("") }
|
||||
val searchListState = rememberLazyListState()
|
||||
val statusMap by UserStatusStore.status.collectAsState()
|
||||
val defaultLastMessage = stringResource(Res.string.chat_last_mesaage)
|
||||
val searchHint = stringResource(resolveSearchHintResource())
|
||||
val searchBarHint = stringResource(resolveSearchTitleResource())
|
||||
var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) }
|
||||
val statusSubscriptionScope = rememberCoroutineScope()
|
||||
var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val hideIme: () -> Unit = {
|
||||
resetFocus(keyboardController, focusManager)
|
||||
}
|
||||
|
||||
val searchQuery = searchText.trim().lowercase().trimStart('@')
|
||||
val filteredDmConversations = remember(dmConversations, searchQuery) {
|
||||
if (searchQuery.isBlank()) {
|
||||
dmConversations
|
||||
} else {
|
||||
dmConversations.filter { conv ->
|
||||
matchesSearchConversations(conv, searchQuery)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val searchUiState by remember(searchText, filteredDmConversations) {
|
||||
derivedStateOf {
|
||||
when {
|
||||
searchText.isBlank() -> SearchScreenState.EmptyQuery
|
||||
filteredDmConversations.isEmpty() -> SearchScreenState.NotFound
|
||||
else -> SearchScreenState.Results
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
// Load cached DM conversations first for immediate display.
|
||||
runCatching {
|
||||
dmConversations = MessageCacheStore.loadCachedDmConversations()
|
||||
}
|
||||
|
||||
// Then refresh from network and update cache + state.
|
||||
runCatching {
|
||||
ApiClient.getDmConversations()
|
||||
}.onSuccess { conversations ->
|
||||
runCatching {
|
||||
conversations.forEach { ProfileCache.mergeFromDmUser(it.user) }
|
||||
MessageCacheStore.replaceDmConversations(conversations)
|
||||
dmConversations = MessageCacheStore.loadCachedDmConversations()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(filteredDmConversations, searchListState) {
|
||||
snapshotFlow {
|
||||
searchListState.layoutInfo.visibleItemsInfo
|
||||
.mapNotNull { item ->
|
||||
filteredDmConversations.getOrNull(item.index)?.otherUserId
|
||||
}
|
||||
.filter { it > 0 }
|
||||
.toSet()
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { visibleIds ->
|
||||
val toSubscribe = visibleIds - subscribedDmUserIds
|
||||
val toUnsubscribe = subscribedDmUserIds - visibleIds
|
||||
|
||||
toSubscribe.forEach { userId ->
|
||||
runCatching { ApiClient.sendSubscribeStatus(userId) }
|
||||
}
|
||||
toUnsubscribe.forEach { userId ->
|
||||
runCatching { ApiClient.sendUnsubscribeStatus(userId) }
|
||||
}
|
||||
subscribedDmUserIds = visibleIds
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
if (subscribedDmUserIds.isNotEmpty()) {
|
||||
statusSubscriptionScope.launch {
|
||||
subscribedDmUserIds.forEach { ApiClient.sendUnsubscribeStatus(it) }
|
||||
}
|
||||
}
|
||||
subscribedDmUserIds = emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = true) {
|
||||
hideIme()
|
||||
onBack()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.imePadding()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
contentWindowInsets = WindowInsets.safeDrawing,
|
||||
topBar = {
|
||||
M3SearchBar(
|
||||
query = searchText,
|
||||
onQueryChange = { searchText = it },
|
||||
onSearch = {},
|
||||
placeholder = searchBarHint,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
leadingIcon = {
|
||||
IconButton(onClick = {
|
||||
hideIme()
|
||||
onBack()
|
||||
}) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
if (searchText.isNotBlank()) {
|
||||
IconButton(onClick = { searchText = "" }) {
|
||||
Icon(imageVector = Icons.Filled.Clear, contentDescription = null)
|
||||
}
|
||||
}
|
||||
},
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
sharedElementKey = SearchBarSharedElement,
|
||||
autoFocus = true
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = searchUiState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
transitionSpec = {
|
||||
fadeIn() togetherWith fadeOut()
|
||||
},
|
||||
label = "search_screen_state"
|
||||
) { state ->
|
||||
when (state) {
|
||||
SearchScreenState.EmptyQuery -> {
|
||||
SearchScreenInfo(
|
||||
title = stringResource(resolveSearchTitleResource()),
|
||||
subtitle = searchHint,
|
||||
showSearchIcon = true
|
||||
)
|
||||
}
|
||||
|
||||
SearchScreenState.NotFound -> {
|
||||
SearchScreenInfo(
|
||||
title = stringResource(resolveSearchNotFoundTitleResource()),
|
||||
subtitle = stringResource(resolveSearchNotFoundMessageResource()),
|
||||
showSearchIcon = false
|
||||
)
|
||||
}
|
||||
|
||||
SearchScreenState.Results -> {
|
||||
SearchConversationsList(
|
||||
listState = searchListState,
|
||||
conversations = filteredDmConversations,
|
||||
defaultLastMessage = defaultLastMessage,
|
||||
statusMap = statusMap,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp),
|
||||
onOpenProfile = { userId ->
|
||||
if (userId != 0) {
|
||||
onOpenProfile(userId)
|
||||
}
|
||||
},
|
||||
onOpenConversation = { userId ->
|
||||
if (userId != 0) {
|
||||
onOpenConversation(userId)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchesSearchConversations(conv: CachedConversation, normalizedQuery: String): Boolean {
|
||||
val cached = ProfileCache.get(conv.otherUserId)
|
||||
val candidates = buildList {
|
||||
add(conv.displayName)
|
||||
cached?.displayName?.let { add(it) }
|
||||
cached?.username?.let { add(it) }
|
||||
}
|
||||
|
||||
return candidates.any { candidate ->
|
||||
candidate.lowercase().contains(normalizedQuery)
|
||||
}
|
||||
}
|
||||
|
||||
private enum class SearchScreenState {
|
||||
EmptyQuery,
|
||||
NotFound,
|
||||
Results
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
private fun SearchScreenInfo(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
showSearchIcon: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tonal = ButtonDefaults.filledTonalButtonColors()
|
||||
val pillWidth = 148.dp
|
||||
val markerHeight = 132.dp
|
||||
val expressiveShape = MaterialShapes.Cookie4Sided.toShape()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.width(pillWidth)
|
||||
.height(markerHeight)
|
||||
) {
|
||||
val markerColor = if (showSearchIcon) {
|
||||
tonal.containerColor
|
||||
} else {
|
||||
tonal.containerColor.copy(alpha = 0.7f)
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.width(pillWidth)
|
||||
.height(markerHeight),
|
||||
shape = expressiveShape,
|
||||
color = markerColor
|
||||
) {
|
||||
Box(Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
if (showSearchIcon) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(88.dp),
|
||||
tint = tonal.contentColor
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package ru.fromchat.ui.main
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -11,27 +13,38 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -39,21 +52,36 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.*
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.ConnectionStateStore
|
||||
import ru.fromchat.api.ConnectionStatus
|
||||
import ru.fromchat.api.ProfileCache
|
||||
import ru.fromchat.api.UserStatus
|
||||
import ru.fromchat.api.UserStatusStore
|
||||
import ru.fromchat.api.db.CachedConversation
|
||||
import ru.fromchat.api.db.MessageCacheStore
|
||||
import ru.fromchat.app_name
|
||||
import ru.fromchat.chat_last_mesaage
|
||||
import ru.fromchat.net.NetworkConnectivity
|
||||
import ru.fromchat.resolveSearchTitleResource
|
||||
import ru.fromchat.presence_online
|
||||
import ru.fromchat.public_chat
|
||||
import ru.fromchat.status_connecting
|
||||
import ru.fromchat.status_updating
|
||||
import ru.fromchat.ui.ConnectingEllipsis
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.dm.DmNav
|
||||
import ru.fromchat.ui.M3SearchBar
|
||||
import ru.fromchat.ui.SearchBarSharedElement
|
||||
import ru.fromchat.ui.branding.FromChatBrandTitle
|
||||
import ru.fromchat.ui.chat.Avatar
|
||||
import ru.fromchat.ui.chat.TypingIndicator
|
||||
import ru.fromchat.ui.dm.DmNav
|
||||
import ru.fromchat.unread_count
|
||||
import ru.fromchat.user_fallback
|
||||
|
||||
@Composable
|
||||
private fun ChatRowAvatar(
|
||||
@@ -82,12 +110,63 @@ private fun ChatRowAvatar(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatsTab() {
|
||||
fun ChatsTab(
|
||||
isVisible: Boolean = true,
|
||||
onOpenSearch: () -> Unit,
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||
) {
|
||||
val navController = LocalNavController.current
|
||||
val connectionStatus by ConnectionStateStore.status.collectAsState()
|
||||
val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
|
||||
var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) }
|
||||
var publicLastMessagePreview by remember { mutableStateOf<String?>(null) }
|
||||
val searchBarHint = stringResource(resolveSearchTitleResource())
|
||||
val tabListState = rememberLazyListState()
|
||||
val statusMap by UserStatusStore.status.collectAsState()
|
||||
var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) }
|
||||
val statusSubscriptionScope = rememberCoroutineScope()
|
||||
val publicConversationsOffset = 1
|
||||
|
||||
LaunchedEffect(dmConversations, tabListState, isVisible, onOpenSearch) {
|
||||
snapshotFlow {
|
||||
if (!isVisible) {
|
||||
emptySet<Int>()
|
||||
} else {
|
||||
tabListState.layoutInfo.visibleItemsInfo
|
||||
.mapNotNull { item ->
|
||||
val dmRowIndex = item.index - publicConversationsOffset
|
||||
dmConversations.getOrNull(dmRowIndex)?.otherUserId
|
||||
}
|
||||
.filter { it > 0 }
|
||||
.toSet()
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { visibleIds ->
|
||||
val toSubscribe = visibleIds - subscribedDmUserIds
|
||||
val toUnsubscribe = subscribedDmUserIds - visibleIds
|
||||
|
||||
toSubscribe.forEach { userId ->
|
||||
runCatching { ApiClient.sendSubscribeStatus(userId) }
|
||||
}
|
||||
toUnsubscribe.forEach { userId ->
|
||||
runCatching { ApiClient.sendUnsubscribeStatus(userId) }
|
||||
}
|
||||
subscribedDmUserIds = visibleIds
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
if (subscribedDmUserIds.isNotEmpty()) {
|
||||
statusSubscriptionScope.launch {
|
||||
subscribedDmUserIds.forEach { ApiClient.sendUnsubscribeStatus(it) }
|
||||
}
|
||||
}
|
||||
subscribedDmUserIds = emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// Load cached DM conversations first for instant offline display.
|
||||
@@ -122,6 +201,8 @@ fun ChatsTab() {
|
||||
val connectingTitle = stringResource(Res.string.status_connecting)
|
||||
val updatingTitle = stringResource(Res.string.status_updating)
|
||||
val brandTitle = stringResource(Res.string.app_name)
|
||||
val defaultLastMessage = stringResource(Res.string.chat_last_mesaage)
|
||||
val publicChatTitle = stringResource(Res.string.public_chat)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -212,87 +293,228 @@ fun ChatsTab() {
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
val publicChatTitle = stringResource(Res.string.public_chat)
|
||||
LazyColumn(contentPadding = innerPadding) {
|
||||
item {
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
ChatRowAvatar(
|
||||
profilePictureUrl = null,
|
||||
displayNameForInitials = publicChatTitle,
|
||||
onClick = { navController.navigate("chats/publicChat") }
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = publicChatTitle,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
val preview = publicLastMessagePreview
|
||||
Text(
|
||||
text = preview ?: stringResource(Res.string.chat_last_mesaage),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
navController.navigate("chats/publicChat")
|
||||
}
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
M3SearchBar(
|
||||
query = "",
|
||||
onQueryChange = {},
|
||||
onSearch = {},
|
||||
placeholder = searchBarHint,
|
||||
readOnly = true,
|
||||
onReadOnlyActivate = onOpenSearch,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp),
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
sharedElementKey = SearchBarSharedElement
|
||||
)
|
||||
|
||||
items(dmConversations.size) { index ->
|
||||
val conv = dmConversations[index]
|
||||
val cached = ProfileCache.get(conv.otherUserId)
|
||||
val avatarUrl = cached?.profilePicture
|
||||
val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() }
|
||||
?: cached?.username?.takeIf { it.isNotBlank() }
|
||||
?: conv.displayName.ifBlank {
|
||||
stringResource(Res.string.user_fallback, conv.otherUserId)
|
||||
ChatConversationsList(
|
||||
listState = tabListState,
|
||||
conversations = dmConversations,
|
||||
publicChatTitle = publicChatTitle,
|
||||
publicLastMessagePreview = publicLastMessagePreview,
|
||||
defaultLastMessage = defaultLastMessage,
|
||||
statusMap = statusMap,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onOpenPublic = { navController.navigate("chats/publicChat") },
|
||||
onOpenProfile = { userId ->
|
||||
if (userId != 0) {
|
||||
navController.navigate("profile/$userId")
|
||||
}
|
||||
val preview = conv.lastMessagePreview?.trim().orEmpty()
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
ChatRowAvatar(
|
||||
profilePictureUrl = avatarUrl,
|
||||
displayNameForInitials = peerTitle,
|
||||
onClick = {
|
||||
if (conv.otherUserId != 0) {
|
||||
navController.navigate("profile/${conv.otherUserId}")
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = peerTitle,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = preview.takeIf { it.isNotEmpty() }
|
||||
?: stringResource(Res.string.chat_last_mesaage),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
if (conv.unreadCount > 0) {
|
||||
Text(stringResource(Res.string.unread_count, conv.unreadCount))
|
||||
}
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
if (conv.otherUserId != 0) {
|
||||
navController.navigate(DmNav.chatRoute(conv.otherUserId))
|
||||
}
|
||||
},
|
||||
onOpenConversation = { userId ->
|
||||
if (userId != 0) {
|
||||
navController.navigate(DmNav.chatRoute(userId))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatConversationsList(
|
||||
listState: LazyListState,
|
||||
conversations: List<CachedConversation>,
|
||||
publicChatTitle: String,
|
||||
publicLastMessagePreview: String?,
|
||||
defaultLastMessage: String,
|
||||
statusMap: Map<Int, UserStatus>,
|
||||
modifier: Modifier = Modifier,
|
||||
onOpenPublic: () -> Unit,
|
||||
onOpenProfile: (Int) -> Unit,
|
||||
onOpenConversation: (Int) -> Unit
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(bottom = 12.dp)
|
||||
) {
|
||||
item {
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
ChatRowAvatar(
|
||||
profilePictureUrl = null,
|
||||
displayNameForInitials = publicChatTitle,
|
||||
onClick = onOpenPublic
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = publicChatTitle,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = publicLastMessagePreview ?: defaultLastMessage,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { onOpenPublic() }
|
||||
)
|
||||
}
|
||||
|
||||
items(conversations.size) { index ->
|
||||
DmConversationRow(
|
||||
conversation = conversations[index],
|
||||
defaultLastMessage = defaultLastMessage,
|
||||
statusMap = statusMap,
|
||||
onOpenProfile = onOpenProfile,
|
||||
onOpenConversation = onOpenConversation
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SearchConversationsList(
|
||||
listState: LazyListState,
|
||||
conversations: List<CachedConversation>,
|
||||
defaultLastMessage: String,
|
||||
statusMap: Map<Int, UserStatus>,
|
||||
modifier: Modifier = Modifier,
|
||||
onOpenProfile: (Int) -> Unit,
|
||||
onOpenConversation: (Int) -> Unit
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(bottom = 12.dp)
|
||||
) {
|
||||
items(conversations.size) { index ->
|
||||
DmConversationRow(
|
||||
conversation = conversations[index],
|
||||
defaultLastMessage = defaultLastMessage,
|
||||
statusMap = statusMap,
|
||||
onOpenProfile = onOpenProfile,
|
||||
onOpenConversation = onOpenConversation
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DmConversationRow(
|
||||
conversation: CachedConversation,
|
||||
defaultLastMessage: String,
|
||||
statusMap: Map<Int, UserStatus>,
|
||||
onOpenProfile: (Int) -> Unit,
|
||||
onOpenConversation: (Int) -> Unit
|
||||
) {
|
||||
val cached = ProfileCache.get(conversation.otherUserId)
|
||||
val avatarUrl = cached?.profilePicture
|
||||
val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() }
|
||||
?: cached?.username?.takeIf { it.isNotBlank() }
|
||||
?: conversation.displayName.ifBlank {
|
||||
stringResource(Res.string.user_fallback, conversation.otherUserId)
|
||||
}
|
||||
val preview = conversation.lastMessagePreview?.trim().orEmpty().ifEmpty { defaultLastMessage }
|
||||
val status = statusMap[conversation.otherUserId]
|
||||
val typingUsers = status?.typingUsernames.orEmpty()
|
||||
val isTyping = typingUsers.isNotEmpty()
|
||||
val isOnline = status?.online ?: (cached?.online == true)
|
||||
val statusKey = when {
|
||||
isTyping -> "typing:${typingUsers.joinToString("|")}"
|
||||
isOnline -> "online"
|
||||
else -> "offline"
|
||||
}
|
||||
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
ChatRowAvatar(
|
||||
profilePictureUrl = avatarUrl,
|
||||
displayNameForInitials = peerTitle,
|
||||
onClick = { onOpenProfile(conversation.otherUserId) }
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = peerTitle,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
AnimatedContent(
|
||||
targetState = statusKey,
|
||||
transitionSpec = {
|
||||
(slideInVertically { it / 2 } + fadeIn()) togetherWith
|
||||
(slideOutVertically { -it / 2 } + fadeOut())
|
||||
},
|
||||
label = "dm_status_${conversation.otherUserId}"
|
||||
) { state ->
|
||||
when {
|
||||
state.startsWith("typing:") -> TypingIndicator(typingUsers = typingUsers)
|
||||
state == "online" -> Text(
|
||||
text = stringResource(Res.string.presence_online),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
else -> Text(
|
||||
text = preview,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
if (conversation.unreadCount > 0) {
|
||||
Text(stringResource(Res.string.unread_count, conversation.unreadCount))
|
||||
}
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
onOpenConversation(conversation.otherUserId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun matchesDmSearch(conv: CachedConversation, normalizedQuery: String): Boolean {
|
||||
val cached = ProfileCache.get(conv.otherUserId)
|
||||
val candidates = buildList {
|
||||
add(conv.displayName)
|
||||
cached?.displayName?.let { add(it) }
|
||||
cached?.username?.let { add(it) }
|
||||
}
|
||||
|
||||
return candidates.any { candidate ->
|
||||
candidate.lowercase().contains(normalizedQuery)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package ru.fromchat.ui.main
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -16,19 +19,21 @@ import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.*
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.utils.exclude
|
||||
import ru.fromchat.ui.main.settings.SettingsTab
|
||||
import ru.fromchat.ui.profile.ProfileScreen
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val PAGE_CHATS = 0
|
||||
private const val PAGE_CONTACTS = 1
|
||||
@@ -37,8 +42,13 @@ private const val PAGE_PROFILE = 3
|
||||
private const val PAGE_COUNT = 4
|
||||
|
||||
@Suppress("AssignedValueIsNeverRead")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainScreen() {
|
||||
fun MainScreen(
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||
) {
|
||||
val navController = LocalNavController.current
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = PAGE_CHATS,
|
||||
pageCount = { PAGE_COUNT },
|
||||
@@ -47,6 +57,7 @@ fun MainScreen() {
|
||||
|
||||
// Pager is the single source of truth; tabs only call animateScrollToPage (no write/read loop).
|
||||
val selectedPage = pagerState.currentPage
|
||||
val isChatsPage = selectedPage == PAGE_CHATS
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
@@ -88,30 +99,41 @@ fun MainScreen() {
|
||||
contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top),
|
||||
modifier = Modifier.imePadding()
|
||||
) { innerPadding ->
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
beyondViewportPageCount = 1,
|
||||
) { page ->
|
||||
when (page) {
|
||||
PAGE_CHATS -> ChatsTab()
|
||||
PAGE_CONTACTS -> ContactsTab()
|
||||
PAGE_SETTINGS -> SettingsTab()
|
||||
PAGE_PROFILE -> {
|
||||
val currentUserId = ApiClient.user?.id
|
||||
ProfileScreen(
|
||||
userId = currentUserId,
|
||||
onBack = {},
|
||||
onChat = { _ -> },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onOpenSettings = {
|
||||
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
|
||||
}
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
beyondViewportPageCount = 1,
|
||||
) { page ->
|
||||
when (page) {
|
||||
PAGE_CHATS -> ChatsTab(
|
||||
isVisible = isChatsPage,
|
||||
onOpenSearch = {
|
||||
navController.navigate("search/conversations")
|
||||
},
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
)
|
||||
PAGE_CONTACTS -> ContactsTab()
|
||||
PAGE_SETTINGS -> SettingsTab()
|
||||
PAGE_PROFILE -> {
|
||||
val currentUserId = ApiClient.user?.id
|
||||
ProfileScreen(
|
||||
userId = currentUserId,
|
||||
onBack = {},
|
||||
onChat = { _ -> },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onOpenSettings = {
|
||||
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
|
||||
}
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ fun SettingsTab() {
|
||||
onAppearance = { nav.navigate(SettingsRoutes.Appearance) },
|
||||
onNotifications = { nav.navigate(SettingsRoutes.Notifications) },
|
||||
onDevices = { nav.navigate(SettingsRoutes.Devices) },
|
||||
onSwitchServer = { nav.navigate("serverConfig") },
|
||||
onAccount = { nav.navigate(SettingsRoutes.Account) },
|
||||
onAbout = { nav.navigate("about") },
|
||||
title = stringResource(Res.string.settings),
|
||||
|
||||
@@ -240,6 +240,7 @@ fun SettingsHubScreen(
|
||||
onAppearance: () -> Unit,
|
||||
onNotifications: () -> Unit,
|
||||
onDevices: () -> Unit,
|
||||
onSwitchServer: () -> Unit,
|
||||
onAccount: () -> Unit,
|
||||
onAbout: () -> Unit,
|
||||
title: String,
|
||||
@@ -335,6 +336,23 @@ fun SettingsHubScreen(
|
||||
)
|
||||
}
|
||||
)
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.change_server),
|
||||
supportingText = stringResource(Res.string.change_server_d),
|
||||
onClick = onSwitchServer,
|
||||
leadingContent = { SettingsListLeadingIcon(Icons.Filled.Storage) },
|
||||
divider = true,
|
||||
dividerColor = settingsSurfaceCutDividerColor(),
|
||||
dividerThickness = SettingsSurfaceCutDividerThickness,
|
||||
trailingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Category(
|
||||
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -74,10 +75,21 @@ import ru.fromchat.*
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.ProfileCache
|
||||
import ru.fromchat.api.UserProfile
|
||||
import ru.fromchat.api.UserStatus
|
||||
import ru.fromchat.api.UserStatusStore
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.chat.Avatar
|
||||
import ru.fromchat.ui.chat.TypingIndicator
|
||||
import ru.fromchat.ui.chat.publicChatProfileSharedAvatarKey
|
||||
import ru.fromchat.ui.scaleOnPress
|
||||
import ru.fromchat.utils.formatLastSeen
|
||||
import ru.fromchat.utils.rememberLastSeenFormatStrings
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
|
||||
private sealed interface ProfileLoadError {
|
||||
data object Generic : ProfileLoadError
|
||||
@@ -331,6 +343,26 @@ fun ProfileScreen(
|
||||
val verificationLabel =
|
||||
if (profile.verified == true) verifiedSupport else verifyPromptSupport
|
||||
val isOwnProfile = ApiClient.user?.id == profile.id
|
||||
val statusMap by UserStatusStore.status.collectAsState()
|
||||
val lastSeenFormat = rememberLastSeenFormatStrings()
|
||||
val statusState = statusMap[profile.id] ?: UserStatus(
|
||||
online = profile.online,
|
||||
lastSeen = profile.lastSeen
|
||||
)
|
||||
val typingUsers = statusState.typingUsernames
|
||||
val statusText = if (statusState.online) {
|
||||
stringResource(Res.string.presence_online)
|
||||
} else {
|
||||
formatLastSeen(false, statusState.lastSeen, lastSeenFormat)
|
||||
.ifEmpty { stringResource(Res.string.presence_recently) }
|
||||
}
|
||||
val statusStateKey = if (typingUsers.isNotEmpty()) {
|
||||
"typing:${typingUsers.joinToString("|")}"
|
||||
} else if (statusState.online) {
|
||||
"online"
|
||||
} else {
|
||||
"offline"
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -345,13 +377,30 @@ fun ProfileScreen(
|
||||
userId = profile.id
|
||||
)
|
||||
}
|
||||
if (!compactIdentityForPublicChat && profile.username.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "@${profile.username}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
AnimatedContent(
|
||||
targetState = statusStateKey,
|
||||
transitionSpec = {
|
||||
(slideInVertically { it / 2 } + fadeIn()) togetherWith
|
||||
(slideOutVertically { -it / 2 } + fadeOut())
|
||||
},
|
||||
label = "profile_status_${profile.id}"
|
||||
) { state ->
|
||||
when {
|
||||
state.startsWith("typing:") -> TypingIndicator(
|
||||
typingUsers = typingUsers
|
||||
)
|
||||
state == "online" -> Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
else -> Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(36.dp))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user