Improve UI, add search bar

This commit is contained in:
2026-04-12 19:47:58 +03:00
Unverified
parent 3b9dca0079
commit cc85731256
17 changed files with 1235 additions and 118 deletions
+1 -3
View File
@@ -1,8 +1,6 @@
<component name="ArtifactManager"> <component name="ArtifactManager">
<artifact type="jar" name="shared"> <artifact type="jar" name="shared">
<output-path>$PROJECT_DIR$/utils/shared/build/libs</output-path> <output-path>$PROJECT_DIR$/utils/shared/build/libs</output-path>
<root id="archive" name="shared.jar"> <root id="archive" name="shared.jar" />
<element id="module-output" name="FromChat.utils.shared.androidMain" />
</root>
</artifact> </artifact>
</component> </component>
+2
View File
@@ -34,9 +34,11 @@
<inspection_tool class="LocalVariableName" enabled="false" level="WEAK WARNING" enabled_by_default="false" /> <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"> <inspection_tool class="MultiplatformPreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" /> <option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool> </inspection_tool>
<inspection_tool class="MultiplatformPreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true"> <inspection_tool class="MultiplatformPreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" /> <option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool> </inspection_tool>
<inspection_tool class="NestedLambdaShadowedImplicitParameter" enabled="false" level="WEAK WARNING" enabled_by_default="false" /> <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"> <inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
@@ -34,6 +34,10 @@
<string name="contacts_empty_body">Список контактов появится здесь, когда функция будет готова.</string> <string name="contacts_empty_body">Список контактов появится здесь, когда функция будет готова.</string>
<string name="public_chat">Общий чат</string> <string name="public_chat">Общий чат</string>
<string name="chat_last_mesaage">Вы: последнее сообщение</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="message_placeholder">Напишите сообщение…</string>
<string name="status_connecting">Соединение</string> <string name="status_connecting">Соединение</string>
<string name="status_updating">Обновление</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="contacts_empty_body">Your contacts will appear here when this feature is ready.</string>
<string name="public_chat">Main chat</string> <string name="public_chat">Main chat</string>
<string name="chat_last_mesaage">You: last message</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> <string name="message_placeholder">Write a message…</string>
<!-- Connection / chat chrome --> <!-- 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( data class UserStatus(
val online: Boolean, val online: Boolean,
val lastSeen: String? = null val lastSeen: String? = null,
val typingUsernames: List<String> = emptyList()
) )
object UserStatusStore { object UserStatusStore {
@@ -15,6 +16,29 @@ object UserStatusStore {
val status: StateFlow<Map<Int, UserStatus>> = _status.asStateFlow() val status: StateFlow<Map<Int, UserStatus>> = _status.asStateFlow()
fun update(userId: Int, online: Boolean, lastSeen: String?) { 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.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -44,6 +45,8 @@ import ru.fromchat.about_link_website
import ru.fromchat.about_version import ru.fromchat.about_version
import ru.fromchat.app_desc import ru.fromchat.app_desc
import ru.fromchat.app_name import ru.fromchat.app_name
import ru.fromchat.debug_tools
import ru.fromchat.debug_tools_d
import ru.fromchat.back import ru.fromchat.back
import ru.fromchat.ui.branding.FromChatBrandTitle 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.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.UserStatusStore
import ru.fromchat.api.ProfileCache import ru.fromchat.api.ProfileCache
import ru.fromchat.api.UpdateSyncManager import ru.fromchat.api.UpdateSyncManager
import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketManager
import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.net.NetworkConnectivity import ru.fromchat.net.NetworkConnectivity
import ru.fromchat.core.config.Config import ru.fromchat.core.config.Config
import ru.fromchat.ui.auth.LoginScreen import ru.fromchat.ui.auth.LoginScreen
import ru.fromchat.ui.auth.RegisterScreen import ru.fromchat.ui.auth.RegisterScreen
import ru.fromchat.ui.chat.PublicChatScreen import ru.fromchat.ui.chat.PublicChatScreen
import ru.fromchat.ui.debug.DebugApiScreen import ru.fromchat.ui.debug.DebugApiScreen
import ru.fromchat.ui.debug.DebugSearchBarDocsScreen
import ru.fromchat.ui.dm.DmChatRoute import ru.fromchat.ui.dm.DmChatRoute
import ru.fromchat.ui.dm.DmNav import ru.fromchat.ui.dm.DmNav
import ru.fromchat.ui.dm.DmProfileRoute import ru.fromchat.ui.dm.DmProfileRoute
import ru.fromchat.ui.main.MainScreen import ru.fromchat.ui.main.MainScreen
import ru.fromchat.ui.main.ChatsSearchScreen
import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.ui.setup.ServerConfigScreen import ru.fromchat.ui.setup.ServerConfigScreen
import ru.fromchat.ui.LocalSystemBarsVisibility 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.SettingsSecurityHubScreen
import ru.fromchat.ui.main.settings.SettingsSecurityPasswordFlowScreen import ru.fromchat.ui.main.settings.SettingsSecurityPasswordFlowScreen
import ru.fromchat.ui.main.settings.SettingsServerToolsScreen import ru.fromchat.ui.main.settings.SettingsServerToolsScreen
import kotlinx.serialization.json.*
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") } 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( private fun NavGraphBuilder.settingsSlideComposable(
route: String, route: String,
animationSpec: FiniteAnimationSpec<IntOffset>, 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 { FromChatTheme {
SharedTransitionLayout { SharedTransitionLayout {
val navController = rememberNavController() val navController = rememberNavController()
@@ -254,7 +298,10 @@ fun App(
} }
composable("chat") { composable("chat") {
MainScreen() MainScreen(
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this
)
} }
composable("chats/publicChat") { 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") { composable("debug") {
DebugApiScreen() DebugApiScreen()
} }
composable("debug/searchbar-docs") {
DebugSearchBarDocsScreen()
}
composable( composable(
route = "profile/{userId}?useSharedElement={useSharedElement}&sourceMessageId={sourceMessageId}", 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 OpenProfileDesc = "Your profile with chat and link actions"
const val OpenDmUser2 = "DM (user 2)" const val OpenDmUser2 = "DM (user 2)"
const val OpenDmUser2Desc = "Open a direct message with user ID 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 BugIconCd = "Debug"
const val StatusPlaceholder = "Status will appear here" const val StatusPlaceholder = "Status will appear here"
const val UnknownError = "Something went wrong. Please try again." const val UnknownError = "Something went wrong. Please try again."
@@ -177,6 +179,24 @@ fun DebugApiScreenContent() {
Text(text = DebugScreenText.OpenAction) 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( HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp), modifier = Modifier.padding(vertical = 8.dp),
thickness = DividerDefaults.Thickness, 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 package ru.fromchat.ui.main
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -11,27 +13,38 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn 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.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.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -39,21 +52,36 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.*
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.ConnectionStateStore import ru.fromchat.api.ConnectionStateStore
import ru.fromchat.api.ConnectionStatus import ru.fromchat.api.ConnectionStatus
import ru.fromchat.api.ProfileCache 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.CachedConversation
import ru.fromchat.api.db.MessageCacheStore 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.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.ConnectingEllipsis
import ru.fromchat.ui.LocalNavController 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.branding.FromChatBrandTitle
import ru.fromchat.ui.chat.Avatar 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 @Composable
private fun ChatRowAvatar( private fun ChatRowAvatar(
@@ -82,12 +110,63 @@ private fun ChatRowAvatar(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ChatsTab() { fun ChatsTab(
isVisible: Boolean = true,
onOpenSearch: () -> Unit,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
val navController = LocalNavController.current val navController = LocalNavController.current
val connectionStatus by ConnectionStateStore.status.collectAsState() val connectionStatus by ConnectionStateStore.status.collectAsState()
val online by NetworkConnectivity.isOnline.collectAsState(initial = true) val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) } var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) }
var publicLastMessagePreview by remember { mutableStateOf<String?>(null) } 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) { LaunchedEffect(Unit) {
// Load cached DM conversations first for instant offline display. // Load cached DM conversations first for instant offline display.
@@ -122,6 +201,8 @@ fun ChatsTab() {
val connectingTitle = stringResource(Res.string.status_connecting) val connectingTitle = stringResource(Res.string.status_connecting)
val updatingTitle = stringResource(Res.string.status_updating) val updatingTitle = stringResource(Res.string.status_updating)
val brandTitle = stringResource(Res.string.app_name) val brandTitle = stringResource(Res.string.app_name)
val defaultLastMessage = stringResource(Res.string.chat_last_mesaage)
val publicChatTitle = stringResource(Res.string.public_chat)
Scaffold( Scaffold(
topBar = { topBar = {
@@ -212,87 +293,228 @@ fun ChatsTab() {
) )
} }
) { innerPadding -> ) { innerPadding ->
val publicChatTitle = stringResource(Res.string.public_chat) Column(
LazyColumn(contentPadding = innerPadding) { modifier = Modifier
item { .fillMaxSize()
ListItem( .padding(innerPadding)
leadingContent = { ) {
ChatRowAvatar( M3SearchBar(
profilePictureUrl = null, query = "",
displayNameForInitials = publicChatTitle, onQueryChange = {},
onClick = { navController.navigate("chats/publicChat") } onSearch = {},
) placeholder = searchBarHint,
}, readOnly = true,
headlineContent = { onReadOnlyActivate = onOpenSearch,
Text( modifier = Modifier
text = publicChatTitle, .fillMaxWidth()
maxLines = 1, .padding(horizontal = 12.dp),
overflow = TextOverflow.Ellipsis leadingIcon = {
) Icon(
}, imageVector = Icons.Default.Search,
supportingContent = { contentDescription = null,
val preview = publicLastMessagePreview tint = MaterialTheme.colorScheme.onSurfaceVariant
Text( )
text = preview ?: stringResource(Res.string.chat_last_mesaage), },
maxLines = 2, sharedTransitionScope = sharedTransitionScope,
overflow = TextOverflow.Ellipsis animatedVisibilityScope = animatedVisibilityScope,
) sharedElementKey = SearchBarSharedElement
}, )
modifier = Modifier.clickable {
navController.navigate("chats/publicChat")
}
)
}
items(dmConversations.size) { index -> ChatConversationsList(
val conv = dmConversations[index] listState = tabListState,
val cached = ProfileCache.get(conv.otherUserId) conversations = dmConversations,
val avatarUrl = cached?.profilePicture publicChatTitle = publicChatTitle,
val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() } publicLastMessagePreview = publicLastMessagePreview,
?: cached?.username?.takeIf { it.isNotBlank() } defaultLastMessage = defaultLastMessage,
?: conv.displayName.ifBlank { statusMap = statusMap,
stringResource(Res.string.user_fallback, conv.otherUserId) modifier = Modifier.fillMaxSize(),
onOpenPublic = { navController.navigate("chats/publicChat") },
onOpenProfile = { userId ->
if (userId != 0) {
navController.navigate("profile/$userId")
} }
val preview = conv.lastMessagePreview?.trim().orEmpty() },
ListItem( onOpenConversation = { userId ->
leadingContent = { if (userId != 0) {
ChatRowAvatar( navController.navigate(DmNav.chatRoute(userId))
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))
}
} }
) }
} )
} }
} }
} }
@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 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.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding 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.Icon
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.* import ru.fromchat.*
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.ui.LocalNavController
import ru.fromchat.utils.exclude import ru.fromchat.utils.exclude
import ru.fromchat.ui.main.settings.SettingsTab import ru.fromchat.ui.main.settings.SettingsTab
import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.ui.profile.ProfileScreen
import kotlinx.coroutines.launch
private const val PAGE_CHATS = 0 private const val PAGE_CHATS = 0
private const val PAGE_CONTACTS = 1 private const val PAGE_CONTACTS = 1
@@ -37,8 +42,13 @@ private const val PAGE_PROFILE = 3
private const val PAGE_COUNT = 4 private const val PAGE_COUNT = 4
@Suppress("AssignedValueIsNeverRead") @Suppress("AssignedValueIsNeverRead")
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainScreen() { fun MainScreen(
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
) {
val navController = LocalNavController.current
val pagerState = rememberPagerState( val pagerState = rememberPagerState(
initialPage = PAGE_CHATS, initialPage = PAGE_CHATS,
pageCount = { PAGE_COUNT }, pageCount = { PAGE_COUNT },
@@ -47,6 +57,7 @@ fun MainScreen() {
// Pager is the single source of truth; tabs only call animateScrollToPage (no write/read loop). // Pager is the single source of truth; tabs only call animateScrollToPage (no write/read loop).
val selectedPage = pagerState.currentPage val selectedPage = pagerState.currentPage
val isChatsPage = selectedPage == PAGE_CHATS
Scaffold( Scaffold(
bottomBar = { bottomBar = {
@@ -88,30 +99,41 @@ fun MainScreen() {
contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top), contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top),
modifier = Modifier.imePadding() modifier = Modifier.imePadding()
) { innerPadding -> ) { innerPadding ->
HorizontalPager( Box(
state = pagerState,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding), .padding(innerPadding)
beyondViewportPageCount = 1, ) {
) { page -> HorizontalPager(
when (page) { state = pagerState,
PAGE_CHATS -> ChatsTab() modifier = Modifier.fillMaxSize(),
PAGE_CONTACTS -> ContactsTab() beyondViewportPageCount = 1,
PAGE_SETTINGS -> SettingsTab() ) { page ->
PAGE_PROFILE -> { when (page) {
val currentUserId = ApiClient.user?.id PAGE_CHATS -> ChatsTab(
ProfileScreen( isVisible = isChatsPage,
userId = currentUserId, onOpenSearch = {
onBack = {}, navController.navigate("search/conversations")
onChat = { _ -> }, },
modifier = Modifier.fillMaxSize(), sharedTransitionScope = sharedTransitionScope,
onOpenSettings = { animatedVisibilityScope = animatedVisibilityScope,
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
}
) )
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) }, onAppearance = { nav.navigate(SettingsRoutes.Appearance) },
onNotifications = { nav.navigate(SettingsRoutes.Notifications) }, onNotifications = { nav.navigate(SettingsRoutes.Notifications) },
onDevices = { nav.navigate(SettingsRoutes.Devices) }, onDevices = { nav.navigate(SettingsRoutes.Devices) },
onSwitchServer = { nav.navigate("serverConfig") },
onAccount = { nav.navigate(SettingsRoutes.Account) }, onAccount = { nav.navigate(SettingsRoutes.Account) },
onAbout = { nav.navigate("about") }, onAbout = { nav.navigate("about") },
title = stringResource(Res.string.settings), title = stringResource(Res.string.settings),
@@ -240,6 +240,7 @@ fun SettingsHubScreen(
onAppearance: () -> Unit, onAppearance: () -> Unit,
onNotifications: () -> Unit, onNotifications: () -> Unit,
onDevices: () -> Unit, onDevices: () -> Unit,
onSwitchServer: () -> Unit,
onAccount: () -> Unit, onAccount: () -> Unit,
onAbout: () -> Unit, onAbout: () -> Unit,
title: String, 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)) Spacer(Modifier.height(20.dp))
Category( Category(
@@ -40,6 +40,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -74,10 +75,21 @@ import ru.fromchat.*
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.ProfileCache import ru.fromchat.api.ProfileCache
import ru.fromchat.api.UserProfile import ru.fromchat.api.UserProfile
import ru.fromchat.api.UserStatus
import ru.fromchat.api.UserStatusStore
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.Avatar import ru.fromchat.ui.chat.Avatar
import ru.fromchat.ui.chat.TypingIndicator
import ru.fromchat.ui.chat.publicChatProfileSharedAvatarKey import ru.fromchat.ui.chat.publicChatProfileSharedAvatarKey
import ru.fromchat.ui.scaleOnPress 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 { private sealed interface ProfileLoadError {
data object Generic : ProfileLoadError data object Generic : ProfileLoadError
@@ -331,6 +343,26 @@ fun ProfileScreen(
val verificationLabel = val verificationLabel =
if (profile.verified == true) verifiedSupport else verifyPromptSupport if (profile.verified == true) verifiedSupport else verifyPromptSupport
val isOwnProfile = ApiClient.user?.id == profile.id 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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -345,13 +377,30 @@ fun ProfileScreen(
userId = profile.id userId = profile.id
) )
} }
if (!compactIdentityForPublicChat && profile.username.isNotBlank()) { Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(4.dp)) AnimatedContent(
Text( targetState = statusStateKey,
text = "@${profile.username}", transitionSpec = {
style = MaterialTheme.typography.bodyMedium, (slideInVertically { it / 2 } + fadeIn()) togetherWith
color = MaterialTheme.colorScheme.onSurfaceVariant (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)) Spacer(modifier = Modifier.height(36.dp))