fix: enhance avatar expansion and collapse animations with overscroll support and haptic feedback in OtherProfileScreen
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
package com.rosetta.messenger.ui.settings
|
||||
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -18,18 +23,23 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
@@ -42,10 +52,13 @@ import com.rosetta.messenger.ui.chats.ChatsListViewModel
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
|
||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||
import com.rosetta.messenger.ui.components.metaball.ProfileMetaballEffect
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import com.rosetta.messenger.utils.AvatarFileManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// Collapsing header constants
|
||||
private val EXPANDED_HEADER_HEIGHT_OTHER = 280.dp
|
||||
@@ -116,28 +129,167 @@ fun OtherProfileScreen(
|
||||
derivedStateOf { (scrollOffset / maxScrollOffset).coerceIn(0f, 1f) }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// TELEGRAM-STYLE AVATAR EXPANSION (Drop/Blob effect)
|
||||
// При свайпе вниз от верха списка - аватарка расширяется
|
||||
// Порог snap = 33% (как в Telegram: expandProgress >= 0.33f)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
var overscrollOffset by remember { mutableFloatStateOf(0f) }
|
||||
val maxOverscroll = with(density) { 250.dp.toPx() } // Максимальное расширение
|
||||
val snapThreshold = maxOverscroll * 0.33f // Telegram: 33%
|
||||
|
||||
// Track dragging state
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
|
||||
// isPulledDown = зафиксировано в раскрытом состоянии (как Telegram)
|
||||
var isPulledDown by remember { mutableStateOf(false) }
|
||||
|
||||
// Velocity для учёта скорости свайпа
|
||||
var lastVelocity by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
// Haptic feedback
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
var hasTriggeredExpandHaptic by remember { mutableStateOf(false) }
|
||||
|
||||
// Проверяем наличие аватара у пользователя
|
||||
val avatars by
|
||||
avatarRepository?.getAvatars(user.publicKey, allDecode = false)?.collectAsState()
|
||||
?: remember { mutableStateOf(emptyList()) }
|
||||
val hasAvatar = avatars.isNotEmpty()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SNAP ANIMATION - как Telegram's expandAnimator
|
||||
// При отпускании пальца: snap к 0 или к max в зависимости от порога
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
val targetOverscroll = when {
|
||||
isDragging -> overscrollOffset // Во время drag - напрямую следуем за пальцем
|
||||
isPulledDown -> maxOverscroll // После snap - держим раскрытым
|
||||
overscrollOffset > snapThreshold -> maxOverscroll // Перешли порог - snap к max
|
||||
else -> 0f // Не дотянули - snap обратно
|
||||
}
|
||||
|
||||
// Telegram: duration = (1 - value) * 250ms при expand, value * 250ms при collapse
|
||||
val currentProgress = (overscrollOffset / maxOverscroll).coerceIn(0f, 1f)
|
||||
val snapDuration = if (targetOverscroll == maxOverscroll) {
|
||||
((1f - currentProgress) * 150).toInt().coerceIn(50, 150)
|
||||
} else {
|
||||
(currentProgress * 150).toInt().coerceIn(50, 150)
|
||||
}
|
||||
|
||||
val animatedOverscroll by animateFloatAsState(
|
||||
targetValue = targetOverscroll,
|
||||
animationSpec = tween(
|
||||
durationMillis = if (isDragging) 0 else snapDuration,
|
||||
easing = LinearOutSlowInEasing
|
||||
),
|
||||
label = "overscroll"
|
||||
)
|
||||
|
||||
// ExpansionProgress для передачи в overlay
|
||||
val expansionProgress = when {
|
||||
collapseProgress > 0.1f -> 0f // Не расширяем при collapse
|
||||
isDragging -> (overscrollOffset / maxOverscroll).coerceIn(0f, 1f)
|
||||
else -> (animatedOverscroll / maxOverscroll).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
// Haptic при достижении порога (как Telegram)
|
||||
LaunchedEffect(expansionProgress) {
|
||||
if (expansionProgress >= 0.33f && !hasTriggeredExpandHaptic && isDragging) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
hasTriggeredExpandHaptic = true
|
||||
} else if (expansionProgress < 0.2f) {
|
||||
hasTriggeredExpandHaptic = false
|
||||
}
|
||||
}
|
||||
|
||||
// DEBUG LOGS
|
||||
Log.d("OtherProfileScroll", "expansionProgress=$expansionProgress, isPulledDown=$isPulledDown, isDragging=$isDragging")
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// NESTED SCROLL - Telegram style with overscroll support
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
val nestedScrollConnection = remember {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
val delta = available.y
|
||||
val newOffset = scrollOffset - delta
|
||||
val consumed =
|
||||
when {
|
||||
delta < 0 && scrollOffset < maxScrollOffset -> {
|
||||
val consumed =
|
||||
(newOffset.coerceIn(0f, maxScrollOffset) - scrollOffset)
|
||||
scrollOffset = newOffset.coerceIn(0f, maxScrollOffset)
|
||||
-consumed
|
||||
}
|
||||
delta > 0 && scrollOffset > 0 -> {
|
||||
val consumed =
|
||||
scrollOffset - newOffset.coerceIn(0f, maxScrollOffset)
|
||||
scrollOffset = newOffset.coerceIn(0f, maxScrollOffset)
|
||||
consumed
|
||||
}
|
||||
else -> 0f
|
||||
isDragging = true
|
||||
|
||||
// Тянем вверх (delta < 0)
|
||||
if (delta < 0) {
|
||||
// Сначала убираем overscroll
|
||||
if (overscrollOffset > 0) {
|
||||
val newOffset = (overscrollOffset + delta).coerceAtLeast(0f)
|
||||
val consumed = overscrollOffset - newOffset
|
||||
overscrollOffset = newOffset
|
||||
// Сбрасываем isPulledDown если вышли из expanded
|
||||
if (overscrollOffset < maxOverscroll * 0.5f) {
|
||||
isPulledDown = false
|
||||
}
|
||||
return Offset(0f, consumed)
|
||||
return Offset(0f, -consumed)
|
||||
}
|
||||
// Затем коллапсируем header
|
||||
if (scrollOffset < maxScrollOffset) {
|
||||
val newScrollOffset = (scrollOffset - delta).coerceIn(0f, maxScrollOffset)
|
||||
val consumed = newScrollOffset - scrollOffset
|
||||
scrollOffset = newScrollOffset
|
||||
return Offset(0f, -consumed)
|
||||
}
|
||||
}
|
||||
|
||||
// Тянем вниз (delta > 0) - раскрываем header
|
||||
if (delta > 0 && scrollOffset > 0) {
|
||||
val newScrollOffset = (scrollOffset - delta).coerceAtLeast(0f)
|
||||
val consumed = scrollOffset - newScrollOffset
|
||||
scrollOffset = newScrollOffset
|
||||
return Offset(0f, consumed)
|
||||
}
|
||||
|
||||
return Offset.Zero
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
// Overscroll при свайпе вниз от верха
|
||||
if (available.y > 0 && scrollOffset == 0f) {
|
||||
// Telegram: сопротивление если ещё не isPulledDown
|
||||
val resistance = if (isPulledDown) 1f else 0.5f
|
||||
val delta = available.y * resistance
|
||||
overscrollOffset = (overscrollOffset + delta).coerceAtMost(maxOverscroll)
|
||||
return Offset(0f, available.y)
|
||||
}
|
||||
return Offset.Zero
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||
lastVelocity = available.y
|
||||
return Velocity.Zero
|
||||
}
|
||||
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
isDragging = false
|
||||
|
||||
// Telegram: snap логика с учётом velocity
|
||||
val velocityThreshold = 1000f
|
||||
|
||||
when {
|
||||
overscrollOffset > snapThreshold || (lastVelocity > velocityThreshold && overscrollOffset > snapThreshold * 0.5f) -> {
|
||||
// Snap to expanded
|
||||
isPulledDown = true
|
||||
}
|
||||
lastVelocity < -velocityThreshold && overscrollOffset > 0 -> {
|
||||
// Fast swipe up - snap to collapsed
|
||||
isPulledDown = false
|
||||
}
|
||||
else -> {
|
||||
// Normal case - snap based on threshold
|
||||
isPulledDown = overscrollOffset > snapThreshold
|
||||
}
|
||||
}
|
||||
|
||||
return Velocity.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,7 +348,7 @@ fun OtherProfileScreen(
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🎨 COLLAPSING HEADER
|
||||
// 🎨 COLLAPSING HEADER with METABALL EFFECT
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
CollapsingOtherProfileHeader(
|
||||
name = user.title.ifEmpty { "Unknown User" },
|
||||
@@ -207,6 +359,8 @@ fun OtherProfileScreen(
|
||||
lastSeen = lastSeen,
|
||||
avatarColors = avatarColors,
|
||||
collapseProgress = collapseProgress,
|
||||
expansionProgress = expansionProgress,
|
||||
hasAvatar = hasAvatar,
|
||||
onBack = onBack,
|
||||
isDarkTheme = isDarkTheme,
|
||||
showAvatarMenu = showAvatarMenu,
|
||||
@@ -239,18 +393,20 @@ fun OtherProfileScreen(
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🎯 COLLAPSING HEADER FOR OTHER PROFILE
|
||||
// 🎯 COLLAPSING HEADER FOR OTHER PROFILE with METABALL EFFECT
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@Composable
|
||||
private fun CollapsingOtherProfileHeader(
|
||||
name: String,
|
||||
username: String,
|
||||
@Suppress("UNUSED_PARAMETER") username: String,
|
||||
publicKey: String,
|
||||
verified: Int,
|
||||
isOnline: Boolean,
|
||||
lastSeen: Long,
|
||||
avatarColors: AvatarColors,
|
||||
collapseProgress: Float,
|
||||
expansionProgress: Float,
|
||||
hasAvatar: Boolean,
|
||||
onBack: () -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
showAvatarMenu: Boolean,
|
||||
@@ -261,34 +417,47 @@ private fun CollapsingOtherProfileHeader(
|
||||
onClearChat: () -> Unit
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val screenWidthDp = configuration.screenWidthDp.dp
|
||||
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
val expandedHeight = EXPANDED_HEADER_HEIGHT_OTHER + statusBarHeight
|
||||
val collapsedHeight = COLLAPSED_HEADER_HEIGHT_OTHER + statusBarHeight
|
||||
|
||||
// Header height меняется только при collapse, НЕ при overscroll
|
||||
val headerHeight =
|
||||
androidx.compose.ui.unit.lerp(expandedHeight, collapsedHeight, collapseProgress)
|
||||
|
||||
// Avatar animation
|
||||
val avatarCenterX = (screenWidthDp - AVATAR_SIZE_EXPANDED_OTHER) / 2
|
||||
val avatarStartY = statusBarHeight + 32.dp
|
||||
val avatarEndY = statusBarHeight - 60.dp
|
||||
val avatarY = androidx.compose.ui.unit.lerp(avatarStartY, avatarEndY, collapseProgress)
|
||||
val avatarSize =
|
||||
androidx.compose.ui.unit.lerp(AVATAR_SIZE_EXPANDED_OTHER, 0.dp, collapseProgress)
|
||||
val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 0.sp, collapseProgress)
|
||||
// Avatar font size for placeholder
|
||||
val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 12.sp, collapseProgress)
|
||||
|
||||
// Haptic feedback при достижении полного квадрата (ТОЛЬКО С АВАТАРКОЙ)
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
var hasTriggeredHaptic by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(expansionProgress, hasAvatar) {
|
||||
if (hasAvatar && expansionProgress >= 0.95f && !hasTriggeredHaptic) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
hasTriggeredHaptic = true
|
||||
} else if (expansionProgress < 0.5f) {
|
||||
hasTriggeredHaptic = false
|
||||
}
|
||||
}
|
||||
|
||||
// Text animation - always centered
|
||||
val textExpandedY = statusBarHeight + 32.dp + AVATAR_SIZE_EXPANDED_OTHER + 48.dp
|
||||
val textDefaultY = expandedHeight - 48.dp
|
||||
val textCollapsedY = statusBarHeight + COLLAPSED_HEADER_HEIGHT_OTHER / 2
|
||||
val textY = androidx.compose.ui.unit.lerp(textExpandedY, textCollapsedY, collapseProgress)
|
||||
val textY = androidx.compose.ui.unit.lerp(textDefaultY, textCollapsedY, collapseProgress)
|
||||
|
||||
val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress)
|
||||
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress)
|
||||
|
||||
// Определяем цвет текста на основе фона
|
||||
val textColor by remember(hasAvatar, avatarColors) {
|
||||
derivedStateOf {
|
||||
if (isColorLight(avatarColors.backgroundColor)) Color.Black else Color.White
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth().height(headerHeight)) {
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🎨 BLURRED AVATAR BACKGROUND
|
||||
@@ -301,6 +470,43 @@ private fun CollapsingOtherProfileHeader(
|
||||
alpha = 0.3f
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 👤 AVATAR with METABALL EFFECT - Liquid merge animation
|
||||
// При скролле вверх аватарка "сливается" с Dynamic Island
|
||||
// При свайпе вниз - расширяется на весь экран
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
ProfileMetaballEffect(
|
||||
collapseProgress = collapseProgress,
|
||||
expansionProgress = expansionProgress,
|
||||
statusBarHeight = statusBarHeight,
|
||||
headerHeight = headerHeight,
|
||||
hasAvatar = hasAvatar,
|
||||
avatarColor = avatarColors.backgroundColor,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
// Содержимое аватара
|
||||
if (hasAvatar && avatarRepository != null) {
|
||||
OtherProfileFullSizeAvatar(
|
||||
publicKey = publicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
} else {
|
||||
// Placeholder без аватарки
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(avatarColors.backgroundColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = getInitials(name),
|
||||
fontSize = avatarFontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = avatarColors.textColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🔙 BACK BUTTON
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@@ -315,7 +521,7 @@ private fun CollapsingOtherProfileHeader(
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = if (isDarkTheme) Color.White else Color(0xFF007AFF),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
@@ -336,9 +542,7 @@ private fun CollapsingOtherProfileHeader(
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = "Profile menu",
|
||||
tint =
|
||||
if (isColorLight(avatarColors.backgroundColor)) Color.Black
|
||||
else Color.White,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
@@ -361,36 +565,7 @@ private fun CollapsingOtherProfileHeader(
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 👤 AVATAR - shrinks and moves up
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
if (avatarSize > 1.dp) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.offset(
|
||||
x =
|
||||
avatarCenterX +
|
||||
(AVATAR_SIZE_EXPANDED_OTHER -
|
||||
avatarSize) / 2,
|
||||
y = avatarY
|
||||
)
|
||||
.size(avatarSize)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.15f))
|
||||
.padding(2.dp)
|
||||
.clip(CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AvatarImage(
|
||||
publicKey = publicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
size = avatarSize - 4.dp,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 📝 TEXT BLOCK - Name + Verified + Online, always centered
|
||||
// 📝 TEXT BLOCK - Name + Verified + Online
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
Column(
|
||||
modifier =
|
||||
@@ -416,11 +591,10 @@ private fun CollapsingOtherProfileHeader(
|
||||
text = name,
|
||||
fontSize = nameFontSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color =
|
||||
if (isColorLight(avatarColors.backgroundColor)) Color.Black
|
||||
else Color.White,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 220.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
@@ -440,15 +614,70 @@ private fun CollapsingOtherProfileHeader(
|
||||
if (isOnline) {
|
||||
Color(0xFF4CAF50)
|
||||
} else {
|
||||
if (isColorLight(avatarColors.backgroundColor))
|
||||
Color.Black.copy(alpha = 0.6f)
|
||||
else Color.White.copy(alpha = 0.6f)
|
||||
textColor.copy(alpha = 0.6f)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
// 🖼 FULL SIZE AVATAR FOR OTHER PROFILE - Fills entire container
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
@Composable
|
||||
private fun OtherProfileFullSizeAvatar(
|
||||
publicKey: String,
|
||||
avatarRepository: AvatarRepository?,
|
||||
isDarkTheme: Boolean = false
|
||||
) {
|
||||
val avatars by
|
||||
avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
|
||||
?: remember { mutableStateOf(emptyList()) }
|
||||
|
||||
// Сохраняем bitmap в remember чтобы не мигал при recomposition
|
||||
var bitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(avatars) {
|
||||
if (avatars.isNotEmpty()) {
|
||||
val newBitmap = withContext(Dispatchers.IO) {
|
||||
AvatarFileManager.base64ToBitmap(avatars.first().base64Data)
|
||||
}
|
||||
bitmap = newBitmap
|
||||
isLoading = false
|
||||
} else {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
bitmap != null -> {
|
||||
Image(
|
||||
bitmap = bitmap!!.asImageBitmap(),
|
||||
contentDescription = "Avatar",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
!isLoading -> {
|
||||
// Placeholder только когда точно нет аватарки
|
||||
val avatarColors = getAvatarColor(publicKey, isDarkTheme)
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(avatarColors.backgroundColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = publicKey.take(2).uppercase(),
|
||||
color = avatarColors.textColor,
|
||||
fontSize = 80.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
// Пока isLoading=true - ничего не показываем (прозрачно)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🚫 BLOCK/UNBLOCK ITEM
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user