diff --git a/app/android/src/main/AndroidManifest.xml b/app/android/src/main/AndroidManifest.xml
index b9099cc..720e2ad 100644
--- a/app/android/src/main/AndroidManifest.xml
+++ b/app/android/src/main/AndroidManifest.xml
@@ -5,6 +5,7 @@
Unit) {
+ AndroidBackHandler(enabled = enabled, onBack = onBack)
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
index dac2e17..1c00e25 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
@@ -28,7 +28,7 @@ import ru.fromchat.ui.auth.LoginScreen
import ru.fromchat.ui.auth.RegisterScreen
import ru.fromchat.ui.chat.PublicChatScreen
import ru.fromchat.ui.debug.DebugApiScreen
-import ru.fromchat.ui.dm.DmScreen
+import ru.fromchat.ui.dm.DmContainerScreen
import ru.fromchat.ui.main.MainScreen
import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.ui.setup.ServerConfigScreen
@@ -177,7 +177,7 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
}
composable("profile/{userId}") { backStackEntry ->
- val userId = backStackEntry.arguments?.getString("userId")?.toIntOrNull()
+ val userId = backStackEntry.savedStateHandle.get("userId")?.toIntOrNull()
ProfileScreen(
userId = userId,
onBack = { navController.navigateUp() },
@@ -186,9 +186,10 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
}
composable("dm/{otherUserId}") { backStackEntry ->
- val otherUserId = backStackEntry.arguments?.getString("otherUserId")?.toIntOrNull() ?: 0
- DmScreen(
- otherUserId = otherUserId
+ val otherUserId = backStackEntry.savedStateHandle.get("otherUserId")?.toIntOrNull() ?: 0
+ DmContainerScreen(
+ otherUserId = otherUserId,
+ onBack = { navController.navigateUp() }
)
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/BackHandler.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/BackHandler.kt
new file mode 100644
index 0000000..8f8e135
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/BackHandler.kt
@@ -0,0 +1,11 @@
+package ru.fromchat.ui
+
+import androidx.compose.runtime.Composable
+
+/**
+ * Handles system back when [enabled]. When enabled, [onBack] is invoked instead of default back.
+ * On Android: uses BackHandler (supports predictive back when app opts in).
+ * On iOS: no-op.
+ */
+@Composable
+expect fun BackHandler(enabled: Boolean, onBack: () -> Unit)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt
index c2c4d8d..8d58b57 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt
@@ -24,7 +24,8 @@ data class ChatPanelState(
val hasMoreMessages: Boolean = false,
val isLoadingMore: Boolean = false,
val typingUsers: List = emptyList(),
- val titleAvatar: AvatarInfo? = null
+ val titleAvatar: AvatarInfo? = null,
+ val profileUserId: Int? = null
)
@Serializable
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt
index cc1d7ad..c7b3ac8 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt
@@ -1,10 +1,17 @@
package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -16,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
@@ -44,7 +52,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.TransformOrigin
+import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
@@ -69,7 +80,6 @@ import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.back
import ru.fromchat.core.Logger
import ru.fromchat.ui.LocalNavController
-import ru.fromchat.ui.chat.Avatar
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
@@ -77,7 +87,14 @@ fun ChatScreen(
panel: ChatPanel,
currentUserId: Int?,
modifier: Modifier = Modifier,
- scrollToMessageId: Int? = null
+ scrollToMessageId: Int? = null,
+ onTitleClick: (() -> Unit)? = null,
+ hideTitleBarAvatar: Boolean = false,
+ onAvatarSlotBounds: ((Rect) -> Unit)? = null,
+ onTitleAvatarChange: ((AvatarInfo?) -> Unit)? = null,
+ sharedTransitionScope: SharedTransitionScope? = null,
+ animatedVisibilityScope: AnimatedVisibilityScope? = null,
+ sharedAvatarKey: Any? = null
) {
var panelState by remember(panel) { mutableStateOf(panel.getState()) }
@@ -98,10 +115,15 @@ fun ChatScreen(
Logger.d("ChatScreen", "Messages count changed: ${panelState.messages.size}")
}
+ LaunchedEffect(panelState.titleAvatar) {
+ onTitleAvatarChange?.invoke(panelState.titleAvatar)
+ }
+
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val navController = LocalNavController.current
+ val profileUserId = panelState.profileUserId
val hazeState = rememberHazeState(blurEnabled = true)
val currentTypingUsers = panelState.typingUsers // Directly use from panelState
@@ -196,12 +218,26 @@ fun ChatScreen(
}
}
- // Scroll to bottom when new messages arrive
+ // Scroll to bottom when new messages arrive.
+ // - Initial composition: jump (no animation) to avoid jank.
+ // - Subsequent messages: only auto-scroll if user is already near bottom.
+ var didInitialScroll by remember(panel) { mutableStateOf(false) }
LaunchedEffect(panelState.messages.size) {
- if (panelState.messages.isNotEmpty()) {
- scope.launch {
- listState.animateScrollToItem(panelState.messages.size - 1)
- }
+ if (panelState.messages.isEmpty()) return@LaunchedEffect
+
+ val lastMessageIndex = panelState.messages.size // account for top spacer item at index 0
+
+ if (!didInitialScroll) {
+ didInitialScroll = true
+ listState.scrollToItem(lastMessageIndex)
+ return@LaunchedEffect
+ }
+
+ val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
+ val totalItems = listState.layoutInfo.totalItemsCount
+ val isNearBottom = lastVisibleIndex >= (totalItems - 3)
+ if (isNearBottom) {
+ listState.animateScrollToItem(lastMessageIndex)
}
}
@@ -210,16 +246,95 @@ fun ChatScreen(
topBar = {
TopAppBar(
title = {
+ val titleInteractionSource = remember { MutableInteractionSource() }
+ var titlePressed by remember { mutableStateOf(false) }
+ LaunchedEffect(titleInteractionSource) {
+ titleInteractionSource.interactions.collect { interaction ->
+ when (interaction) {
+ is PressInteraction.Press -> titlePressed = true
+ is PressInteraction.Release -> titlePressed = false
+ }
+ }
+ }
+ val titleScale by animateFloatAsState(
+ targetValue = if (titlePressed) 0.96f else 1f,
+ animationSpec = tween(durationMillis = 100),
+ label = "title_scale"
+ )
Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .graphicsLayer(
+ scaleX = titleScale,
+ scaleY = titleScale,
+ transformOrigin = TransformOrigin.Center
+ )
+ .then(
+ if (profileUserId != null && onTitleClick != null) {
+ Modifier.clickable(
+ interactionSource = titleInteractionSource,
+ indication = null
+ ) { onTitleClick() }
+ } else Modifier
+ ),
verticalAlignment = Alignment.CenterVertically
) {
- panelState.titleAvatar?.let { avatar ->
- Avatar(
- profilePictureUrl = avatar.profilePictureUrl,
- displayName = avatar.displayName,
- size = 36.dp
- )
- Spacer(modifier = Modifier.width(8.dp))
+ when {
+ sharedAvatarKey != null && sharedTransitionScope != null && animatedVisibilityScope != null -> {
+ val avatar = panelState.titleAvatar
+ val displayName = avatar?.displayName?.takeIf { it.isNotBlank() }
+ ?: panelState.title.takeIf { it.isNotBlank() }
+ ?: "?"
+ with(sharedTransitionScope) {
+ Avatar(
+ profilePictureUrl = avatar?.profilePictureUrl,
+ displayName = displayName,
+ size = 36.dp,
+ modifier = Modifier
+ .sharedElement(
+ rememberSharedContentState(key = sharedAvatarKey),
+ animatedVisibilityScope = animatedVisibilityScope
+ )
+ .size(36.dp)
+ )
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ }
+ !hideTitleBarAvatar -> {
+ panelState.titleAvatar?.let { avatar ->
+ Avatar(
+ profilePictureUrl = avatar.profilePictureUrl,
+ displayName = avatar.displayName,
+ size = 36.dp
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ }
+ }
+ onAvatarSlotBounds != null -> {
+ Box(
+ modifier = Modifier
+ .size(36.dp)
+ .onGloballyPositioned { coords ->
+ val pos = coords.positionInRoot()
+ val sz = coords.size
+ onAvatarSlotBounds(
+ Rect(
+ pos.x,
+ pos.y,
+ pos.x + sz.width.toFloat(),
+ pos.y + sz.height.toFloat()
+ )
+ )
+ }
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ }
+ else -> {
+ panelState.titleAvatar?.let {
+ Spacer(modifier = Modifier.width(36.dp))
+ Spacer(modifier = Modifier.width(8.dp))
+ }
+ }
}
Column(Modifier.fillMaxWidth()) {
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmContainerScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmContainerScreen.kt
new file mode 100644
index 0000000..c6507bb
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmContainerScreen.kt
@@ -0,0 +1,72 @@
+package ru.fromchat.ui.dm
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.SharedTransitionLayout
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import ru.fromchat.ui.BackHandler
+import ru.fromchat.ui.profile.ProfileScreen
+
+private const val TRANSITION_MS = 320
+
+@Composable
+fun DmContainerScreen(
+ otherUserId: Int,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ var showProfile by remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
+ val panel = remember(otherUserId) {
+ DmPanelCache.getOrCreate(otherUserId, scope)
+ }
+
+ BackHandler(enabled = showProfile) {
+ showProfile = false
+ }
+
+ // One stable key per DM to match the same avatar in chat + profile.
+ val sharedAvatarKey = remember(otherUserId) { "dm-avatar-$otherUserId" }
+
+ SharedTransitionLayout(modifier = modifier.fillMaxSize()) {
+ AnimatedContent(
+ targetState = showProfile,
+ transitionSpec = {
+ fadeIn(animationSpec = tween(TRANSITION_MS)) togetherWith
+ fadeOut(animationSpec = tween(TRANSITION_MS))
+ },
+ label = "dm_profile"
+ ) { targetState ->
+ if (!targetState) {
+ DmScreen(
+ panel = panel,
+ modifier = Modifier.fillMaxSize(),
+ onTitleClick = { showProfile = true },
+ sharedTransitionScope = this@SharedTransitionLayout,
+ animatedVisibilityScope = this@AnimatedContent,
+ sharedAvatarKey = sharedAvatarKey
+ )
+ } else {
+ ProfileScreen(
+ userId = otherUserId,
+ onBack = { showProfile = false },
+ onChat = { _ -> showProfile = false },
+ modifier = Modifier.fillMaxSize(),
+ sharedTransitionScope = this@SharedTransitionLayout,
+ animatedVisibilityScope = this@AnimatedContent,
+ sharedAvatarKey = sharedAvatarKey
+ )
+ }
+ }
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt
index d54760f..7aef792 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt
@@ -38,8 +38,8 @@ class DmPanel(
private var otherProfilePicture: String? = null
init {
- updateState { it.copy(title = "Direct message") }
- coroutineScope.launch(Dispatchers.IO) {
+ updateState { it.copy(title = "Direct message", profileUserId = otherUserId) }
+ coroutineScope.launch(Dispatchers.Default) {
runCatching {
ApiClient.getProfileById(otherUserId)
}.onSuccess { profile ->
@@ -52,7 +52,8 @@ class DmPanel(
titleAvatar = AvatarInfo(
displayName = displayName,
profilePictureUrl = otherProfilePicture
- )
+ ),
+ profileUserId = otherUserId
)
}
}
@@ -105,7 +106,7 @@ class DmPanel(
json.decodeFromJsonElement(DmEnvelope.serializer(), element)
}.getOrNull() ?: return
if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) return
- scope.launch(Dispatchers.IO) {
+ scope.launch(Dispatchers.Default) {
val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
if (plaintext != null) {
addMessage(createMessage(envelope, plaintext))
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanelCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanelCache.kt
new file mode 100644
index 0000000..0177b8b
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanelCache.kt
@@ -0,0 +1,29 @@
+package ru.fromchat.ui.dm
+
+import kotlinx.coroutines.CoroutineScope
+import ru.fromchat.api.ApiClient
+
+/**
+ * Cache of DM panels by otherUserId so that when navigating back from profile
+ * we reuse the same panel and don't reload messages.
+ */
+object DmPanelCache {
+ private val panels = mutableMapOf()
+
+ fun getOrCreate(
+ otherUserId: Int,
+ scope: CoroutineScope
+ ): DmPanel {
+ return panels.getOrPut(otherUserId) {
+ DmPanel(
+ otherUserId = otherUserId,
+ coroutineScope = scope,
+ currentUserId = ApiClient.user?.id
+ )
+ }
+ }
+
+ fun remove(otherUserId: Int) {
+ panels.remove(otherUserId)?.destroy()
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmScreen.kt
index 016fbe2..b20ad6b 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmScreen.kt
@@ -1,32 +1,31 @@
package ru.fromchat.ui.dm
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
-import kotlinx.coroutines.launch
+import androidx.compose.ui.geometry.Rect
import ru.fromchat.api.ApiClient
import ru.fromchat.ui.chat.ChatScreen
@Composable
fun DmScreen(
- otherUserId: Int,
- modifier: Modifier = Modifier
+ panel: DmPanel,
+ modifier: Modifier = Modifier,
+ onTitleClick: (() -> Unit)? = null,
+ hideTitleBarAvatar: Boolean = false,
+ onAvatarSlotBounds: ((Rect) -> Unit)? = null,
+ onTitleAvatarChange: ((ru.fromchat.ui.chat.AvatarInfo?) -> Unit)? = null,
+ sharedTransitionScope: SharedTransitionScope? = null,
+ animatedVisibilityScope: AnimatedVisibilityScope? = null,
+ sharedAvatarKey: Any? = null
) {
- val coroutineScope = rememberCoroutineScope()
val currentUserId = ApiClient.user?.id
- val panel = remember(otherUserId) {
- DmPanel(
- otherUserId = otherUserId,
- coroutineScope = coroutineScope,
- currentUserId = currentUserId
- )
- }
- LaunchedEffect(otherUserId) {
- coroutineScope.launch {
+ LaunchedEffect(panel) {
+ if (panel.getState().messages.isEmpty()) {
panel.loadMessages()
}
}
@@ -34,6 +33,13 @@ fun DmScreen(
ChatScreen(
panel = panel,
currentUserId = currentUserId,
- modifier = modifier.fillMaxSize()
+ modifier = modifier.fillMaxSize(),
+ onTitleClick = onTitleClick,
+ hideTitleBarAvatar = hideTitleBarAvatar,
+ onAvatarSlotBounds = onAvatarSlotBounds,
+ onTitleAvatarChange = onTitleAvatarChange,
+ sharedTransitionScope = sharedTransitionScope,
+ animatedVisibilityScope = animatedVisibilityScope,
+ sharedAvatarKey = sharedAvatarKey
)
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt
index 2639005..3652387 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt
@@ -1,5 +1,7 @@
package ru.fromchat.ui.profile
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -10,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Button
@@ -22,7 +23,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
@@ -33,7 +33,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
@@ -41,6 +44,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import ru.fromchat.api.ApiClient
import ru.fromchat.api.UserProfile
+import ru.fromchat.ui.chat.Avatar
private data class ProfileUiState(
val profile: UserProfile? = null,
@@ -54,7 +58,13 @@ private data class ProfileUiState(
fun ProfileScreen(
userId: Int?,
onBack: () -> Unit,
- onChat: (Int) -> Unit
+ onChat: (Int) -> Unit,
+ hideAvatar: Boolean = false,
+ onAvatarSlotBounds: ((Rect) -> Unit)? = null,
+ modifier: Modifier = Modifier,
+ sharedTransitionScope: SharedTransitionScope? = null,
+ animatedVisibilityScope: AnimatedVisibilityScope? = null,
+ sharedAvatarKey: Any? = null
) {
val clipboardManager: ClipboardManager = LocalClipboardManager.current
var state by remember { mutableStateOf(ProfileUiState()) }
@@ -78,6 +88,7 @@ fun ProfileScreen(
}
Scaffold(
+ modifier = modifier,
topBar = {
TopAppBar(
title = { Text(text = "Profile") },
@@ -96,49 +107,88 @@ fun ProfileScreen(
.padding(16.dp)
) {
val errorMessage = state.error
- when {
- state.isLoading -> {
- CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
- }
- errorMessage != null -> {
- Text(
- text = errorMessage,
- color = MaterialTheme.colorScheme.error,
- modifier = Modifier.align(Alignment.Center)
- )
- }
- state.profile != null -> {
- val profile = state.profile!!
- val profileLink = profile.username.takeIf { it.isNotBlank() }
- ?.let { "https://fromchat.ru/@$it" }
- ?: "https://fromchat.ru/?u=${profile.id}"
- val displayName = profile.displayName?.takeIf { it.isNotBlank() } ?: profile.username
- val initials = profile.displayName?.firstOrNull()
- ?: profile.username.firstOrNull()
- ?: '?'
+ val profile = state.profile
+ val displayName = profile?.displayName?.takeIf { it.isNotBlank() }
+ ?: profile?.username?.takeIf { it.isNotBlank() }
+ ?: "?"
- Column(
- modifier = Modifier.fillMaxSize(),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Top
- ) {
- Surface(
- modifier = Modifier
- .size(128.dp)
- .padding(top = 16.dp),
- shape = CircleShape,
- color = MaterialTheme.colorScheme.primaryContainer
- ) {
- Box(contentAlignment = Alignment.Center) {
- Text(
- text = initials.toString(),
- style = MaterialTheme.typography.headlineLarge,
- color = MaterialTheme.colorScheme.onPrimaryContainer
- )
- }
+ val useSharedAvatar = sharedTransitionScope != null &&
+ animatedVisibilityScope != null &&
+ sharedAvatarKey != null
+
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Top
+ ) {
+ when {
+ useSharedAvatar -> {
+ with(sharedTransitionScope!!) {
+ Avatar(
+ profilePictureUrl = profile?.profilePicture,
+ displayName = displayName,
+ size = 128.dp,
+ modifier = Modifier
+ .padding(top = 16.dp)
+ .sharedElement(
+ rememberSharedContentState(key = sharedAvatarKey!!),
+ animatedVisibilityScope = animatedVisibilityScope!!
+ )
+ .size(128.dp)
+ )
}
-
Spacer(modifier = Modifier.height(12.dp))
+ }
+ !hideAvatar -> {
+ Avatar(
+ profilePictureUrl = profile?.profilePicture,
+ displayName = displayName,
+ size = 128.dp,
+ modifier = Modifier.padding(top = 16.dp)
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ }
+ onAvatarSlotBounds != null -> {
+ Box(
+ modifier = Modifier
+ .padding(top = 16.dp)
+ .size(128.dp)
+ .onGloballyPositioned { coords ->
+ val pos = coords.positionInRoot()
+ val sz = coords.size
+ onAvatarSlotBounds(
+ Rect(
+ pos.x,
+ pos.y,
+ pos.x + sz.width.toFloat(),
+ pos.y + sz.height.toFloat()
+ )
+ )
+ }
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ }
+ else -> {
+ Spacer(modifier = Modifier.height(16.dp + 128.dp + 12.dp))
+ }
+ }
+
+ when {
+ state.isLoading -> {
+ CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp))
+ }
+ errorMessage != null -> {
+ Text(
+ text = errorMessage,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(top = 24.dp)
+ )
+ }
+ profile != null -> {
+ val profileLink = profile.username.takeIf { it.isNotBlank() }
+ ?.let { "https://fromchat.ru/@$it" }
+ ?: "https://fromchat.ru/?u=${profile.id}"
+
Text(
text = displayName,
style = MaterialTheme.typography.titleLarge,
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/BackHandler.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/BackHandler.ios.kt
new file mode 100644
index 0000000..657e09c
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/BackHandler.ios.kt
@@ -0,0 +1,8 @@
+package ru.fromchat.ui
+
+import androidx.compose.runtime.Composable
+
+@Composable
+actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
+ // iOS has no system back button; back is handled by navigation.
+}