Working shared element transition

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-02-07 13:50:50 +03:00
Unverified
parent ed92ab807d
commit ab58b5f22d
13 changed files with 388 additions and 83 deletions
+1
View File
@@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:enableOnBackInvokedCallback="true"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
+1
View File
@@ -37,6 +37,7 @@ kotlin {
androidMain.dependencies { androidMain.dependencies {
implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.okhttp)
implementation(libs.androidx.activity.compose)
// NaCl box implementation for transport encryption (Android/JVM only) // NaCl box implementation for transport encryption (Android/JVM only)
implementation("org.purejava:tweetnacl-java:1.1.3") implementation("org.purejava:tweetnacl-java:1.1.3")
} }
@@ -0,0 +1,9 @@
package ru.fromchat.ui
import androidx.activity.compose.BackHandler as AndroidBackHandler
import androidx.compose.runtime.Composable
@Composable
actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
AndroidBackHandler(enabled = enabled, onBack = onBack)
}
@@ -28,7 +28,7 @@ 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.dm.DmScreen import ru.fromchat.ui.dm.DmContainerScreen
import ru.fromchat.ui.main.MainScreen import ru.fromchat.ui.main.MainScreen
import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.ui.setup.ServerConfigScreen import ru.fromchat.ui.setup.ServerConfigScreen
@@ -177,7 +177,7 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
} }
composable("profile/{userId}") { backStackEntry -> composable("profile/{userId}") { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId")?.toIntOrNull() val userId = backStackEntry.savedStateHandle.get<String>("userId")?.toIntOrNull()
ProfileScreen( ProfileScreen(
userId = userId, userId = userId,
onBack = { navController.navigateUp() }, onBack = { navController.navigateUp() },
@@ -186,9 +186,10 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
} }
composable("dm/{otherUserId}") { backStackEntry -> composable("dm/{otherUserId}") { backStackEntry ->
val otherUserId = backStackEntry.arguments?.getString("otherUserId")?.toIntOrNull() ?: 0 val otherUserId = backStackEntry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
DmScreen( DmContainerScreen(
otherUserId = otherUserId otherUserId = otherUserId,
onBack = { navController.navigateUp() }
) )
} }
@@ -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)
@@ -24,7 +24,8 @@ data class ChatPanelState(
val hasMoreMessages: Boolean = false, val hasMoreMessages: Boolean = false,
val isLoadingMore: Boolean = false, val isLoadingMore: Boolean = false,
val typingUsers: List<TypingUser> = emptyList(), val typingUsers: List<TypingUser> = emptyList(),
val titleAvatar: AvatarInfo? = null val titleAvatar: AvatarInfo? = null,
val profileUserId: Int? = null
) )
@Serializable @Serializable
@@ -1,10 +1,17 @@
package ru.fromchat.ui.chat package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedContent 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.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.height
import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -44,7 +52,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color 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.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
@@ -69,7 +80,6 @@ import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.back import ru.fromchat.back
import ru.fromchat.core.Logger import ru.fromchat.core.Logger
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.Avatar
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable @Composable
@@ -77,7 +87,14 @@ fun ChatScreen(
panel: ChatPanel, panel: ChatPanel,
currentUserId: Int?, currentUserId: Int?,
modifier: Modifier = Modifier, 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()) } var panelState by remember(panel) { mutableStateOf(panel.getState()) }
@@ -98,10 +115,15 @@ fun ChatScreen(
Logger.d("ChatScreen", "Messages count changed: ${panelState.messages.size}") Logger.d("ChatScreen", "Messages count changed: ${panelState.messages.size}")
} }
LaunchedEffect(panelState.titleAvatar) {
onTitleAvatarChange?.invoke(panelState.titleAvatar)
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val navController = LocalNavController.current val navController = LocalNavController.current
val profileUserId = panelState.profileUserId
val hazeState = rememberHazeState(blurEnabled = true) val hazeState = rememberHazeState(blurEnabled = true)
val currentTypingUsers = panelState.typingUsers // Directly use from panelState 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) { LaunchedEffect(panelState.messages.size) {
if (panelState.messages.isNotEmpty()) { if (panelState.messages.isEmpty()) return@LaunchedEffect
scope.launch {
listState.animateScrollToItem(panelState.messages.size - 1) 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,9 +246,61 @@ fun ChatScreen(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { 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( 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 verticalAlignment = Alignment.CenterVertically
) { ) {
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 -> panelState.titleAvatar?.let { avatar ->
Avatar( Avatar(
profilePictureUrl = avatar.profilePictureUrl, profilePictureUrl = avatar.profilePictureUrl,
@@ -221,6 +309,33 @@ fun ChatScreen(
) )
Spacer(modifier = Modifier.width(8.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()) { Column(Modifier.fillMaxWidth()) {
Text( Text(
@@ -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
)
}
}
}
}
@@ -38,8 +38,8 @@ class DmPanel(
private var otherProfilePicture: String? = null private var otherProfilePicture: String? = null
init { init {
updateState { it.copy(title = "Direct message") } updateState { it.copy(title = "Direct message", profileUserId = otherUserId) }
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.Default) {
runCatching { runCatching {
ApiClient.getProfileById(otherUserId) ApiClient.getProfileById(otherUserId)
}.onSuccess { profile -> }.onSuccess { profile ->
@@ -52,7 +52,8 @@ class DmPanel(
titleAvatar = AvatarInfo( titleAvatar = AvatarInfo(
displayName = displayName, displayName = displayName,
profilePictureUrl = otherProfilePicture profilePictureUrl = otherProfilePicture
) ),
profileUserId = otherUserId
) )
} }
} }
@@ -105,7 +106,7 @@ class DmPanel(
json.decodeFromJsonElement(DmEnvelope.serializer(), element) json.decodeFromJsonElement(DmEnvelope.serializer(), element)
}.getOrNull() ?: return }.getOrNull() ?: return
if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) return if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) return
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.Default) {
val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
if (plaintext != null) { if (plaintext != null) {
addMessage(createMessage(envelope, plaintext)) addMessage(createMessage(envelope, plaintext))
@@ -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<Int, DmPanel>()
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()
}
}
@@ -1,32 +1,31 @@
package ru.fromchat.ui.dm package ru.fromchat.ui.dm
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import kotlinx.coroutines.launch import androidx.compose.ui.geometry.Rect
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.ui.chat.ChatScreen import ru.fromchat.ui.chat.ChatScreen
@Composable @Composable
fun DmScreen( fun DmScreen(
otherUserId: Int, panel: DmPanel,
modifier: Modifier = Modifier 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 currentUserId = ApiClient.user?.id
val panel = remember(otherUserId) {
DmPanel(
otherUserId = otherUserId,
coroutineScope = coroutineScope,
currentUserId = currentUserId
)
}
LaunchedEffect(otherUserId) { LaunchedEffect(panel) {
coroutineScope.launch { if (panel.getState().messages.isEmpty()) {
panel.loadMessages() panel.loadMessages()
} }
} }
@@ -34,6 +33,13 @@ fun DmScreen(
ChatScreen( ChatScreen(
panel = panel, panel = panel,
currentUserId = currentUserId, currentUserId = currentUserId,
modifier = modifier.fillMaxSize() modifier = modifier.fillMaxSize(),
onTitleClick = onTitleClick,
hideTitleBarAvatar = hideTitleBarAvatar,
onAvatarSlotBounds = onAvatarSlotBounds,
onTitleAvatarChange = onTitleAvatarChange,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedAvatarKey = sharedAvatarKey
) )
} }
@@ -1,5 +1,7 @@
package ru.fromchat.ui.profile 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -22,7 +23,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -33,7 +33,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color 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.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
@@ -41,6 +44,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.UserProfile import ru.fromchat.api.UserProfile
import ru.fromchat.ui.chat.Avatar
private data class ProfileUiState( private data class ProfileUiState(
val profile: UserProfile? = null, val profile: UserProfile? = null,
@@ -54,7 +58,13 @@ private data class ProfileUiState(
fun ProfileScreen( fun ProfileScreen(
userId: Int?, userId: Int?,
onBack: () -> Unit, 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 val clipboardManager: ClipboardManager = LocalClipboardManager.current
var state by remember { mutableStateOf(ProfileUiState()) } var state by remember { mutableStateOf(ProfileUiState()) }
@@ -78,6 +88,7 @@ fun ProfileScreen(
} }
Scaffold( Scaffold(
modifier = modifier,
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(text = "Profile") }, title = { Text(text = "Profile") },
@@ -96,49 +107,88 @@ fun ProfileScreen(
.padding(16.dp) .padding(16.dp)
) { ) {
val errorMessage = state.error val errorMessage = state.error
when { val profile = state.profile
state.isLoading -> { val displayName = profile?.displayName?.takeIf { it.isNotBlank() }
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) ?: profile?.username?.takeIf { it.isNotBlank() }
} ?: "?"
errorMessage != null -> {
Text( val useSharedAvatar = sharedTransitionScope != null &&
text = errorMessage, animatedVisibilityScope != null &&
color = MaterialTheme.colorScheme.error, sharedAvatarKey != null
modifier = Modifier.align(Alignment.Center)
)
}
state.profile != null -> {
val profile = state.profile!!
val profileLink = profile.username.takeIf { it.isNotBlank() }
?.let { "https://fromchat.ru/@$it" }
?: "https://fromchat.ru/?u=${profile.id}"
val displayName = profile.displayName?.takeIf { it.isNotBlank() } ?: profile.username
val initials = profile.displayName?.firstOrNull()
?: profile.username.firstOrNull()
?: '?'
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top verticalArrangement = Arrangement.Top
) { ) {
Surface( when {
useSharedAvatar -> {
with(sharedTransitionScope!!) {
Avatar(
profilePictureUrl = profile?.profilePicture,
displayName = displayName,
size = 128.dp,
modifier = Modifier modifier = Modifier
.size(128.dp) .padding(top = 16.dp)
.padding(top = 16.dp), .sharedElement(
shape = CircleShape, rememberSharedContentState(key = sharedAvatarKey!!),
color = MaterialTheme.colorScheme.primaryContainer animatedVisibilityScope = animatedVisibilityScope!!
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = initials.toString(),
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer
) )
.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))
} }
} }
Spacer(modifier = Modifier.height(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(
text = displayName, text = displayName,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
@@ -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.
}