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. +}