mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Working shared element transition
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
|
||||
@@ -37,6 +37,7 @@ kotlin {
|
||||
|
||||
androidMain.dependencies {
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
// NaCl box implementation for transport encryption (Android/JVM only)
|
||||
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.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<String>("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<String>("otherUserId")?.toIntOrNull() ?: 0
|
||||
DmContainerScreen(
|
||||
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 isLoadingMore: Boolean = false,
|
||||
val typingUsers: List<TypingUser> = emptyList(),
|
||||
val titleAvatar: AvatarInfo? = null
|
||||
val titleAvatar: AvatarInfo? = null,
|
||||
val profileUserId: Int? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -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,9 +246,61 @@ 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
|
||||
) {
|
||||
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,
|
||||
@@ -221,6 +309,33 @@ fun ChatScreen(
|
||||
)
|
||||
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()) {
|
||||
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
|
||||
|
||||
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))
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
?: "?"
|
||||
|
||||
val useSharedAvatar = sharedTransitionScope != null &&
|
||||
animatedVisibilityScope != null &&
|
||||
sharedAvatarKey != null
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Surface(
|
||||
when {
|
||||
useSharedAvatar -> {
|
||||
with(sharedTransitionScope!!) {
|
||||
Avatar(
|
||||
profilePictureUrl = profile?.profilePicture,
|
||||
displayName = displayName,
|
||||
size = 128.dp,
|
||||
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
|
||||
.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))
|
||||
}
|
||||
}
|
||||
|
||||
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 = displayName,
|
||||
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.
|
||||
}
|
||||
Reference in New Issue
Block a user