feat: Implement multi-image editing with captions in ChatDetailScreen and enhance ProfileScreen with overscroll effects

This commit is contained in:
2026-01-30 02:59:43 +05:00
parent 6720057ebc
commit 7691926ef6
5 changed files with 681 additions and 225 deletions

View File

@@ -32,11 +32,13 @@ import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.shadow
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.graphics.vector.ImageVector
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.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
@@ -644,20 +646,44 @@ private fun CollapsingProfileHeader(
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
// Header heights
val expandedHeight = EXPANDED_HEADER_HEIGHT + statusBarHeight
// По умолчанию header = ширина экрана минус отступ для Account, при скролле уменьшается
val expandedHeight = screenWidthDp - 60.dp // Высота header (меньше чтобы не перекрывать Account)
val collapsedHeight = COLLAPSED_HEADER_HEIGHT + statusBarHeight
// Animated header height
val headerHeight = androidx.compose.ui.unit.lerp(expandedHeight, collapsedHeight, collapseProgress)
// ═══════════════════════════════════════════════════════════
// 👤 AVATAR - shrinks and moves UP until disappears
// 👤 AVATAR - По умолчанию квадратный во весь экран, при скролле становится круглым
// ═══════════════════════════════════════════════════════════
// Размер: ширина = screenWidthDp (полная ширина), высота = expandedHeight
// При скролле уменьшается до 0
val avatarWidth = androidx.compose.ui.unit.lerp(screenWidthDp, 0.dp, collapseProgress)
val avatarHeight = androidx.compose.ui.unit.lerp(expandedHeight, 0.dp, collapseProgress)
// Для cornerRadius и других расчётов используем меньшую сторону
val avatarSize = minOf(avatarWidth, avatarHeight)
// Позиция X: от 0 (весь экран) до центра
val avatarCenterX = (screenWidthDp - AVATAR_SIZE_EXPANDED) / 2
val avatarStartY = statusBarHeight + 32.dp
val avatarEndY = statusBarHeight - 60.dp // Moves above screen
val avatarY = androidx.compose.ui.unit.lerp(avatarStartY, avatarEndY, collapseProgress)
val avatarSize = androidx.compose.ui.unit.lerp(AVATAR_SIZE_EXPANDED, 0.dp, collapseProgress)
val avatarX = androidx.compose.ui.unit.lerp(0.dp, avatarCenterX + (AVATAR_SIZE_EXPANDED - avatarSize) / 2, collapseProgress.coerceIn(0f, 0.5f) * 2f)
// Позиция Y: от 0 (верх экрана) до обычной позиции, потом уходит вверх
val avatarStartY = 0.dp // Начинаем с самого верха (закрываем status bar)
val avatarMidY = statusBarHeight + 32.dp // Средняя позиция (круглый аватар)
val avatarEndY = statusBarHeight - 60.dp // Уходит вверх за экран
val avatarY = if (collapseProgress < 0.5f) {
// Первая половина: от 0 до обычной позиции
androidx.compose.ui.unit.lerp(avatarStartY, avatarMidY, collapseProgress * 2f)
} else {
// Вторая половина: уходит вверх
androidx.compose.ui.unit.lerp(avatarMidY, avatarEndY, (collapseProgress - 0.5f) * 2f)
}
// Закругление: от 0 (квадрат) до половины размера (круг)
val cornerRadius = androidx.compose.ui.unit.lerp(0.dp, avatarSize / 2, collapseProgress.coerceIn(0f, 0.3f) / 0.3f)
val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 0.sp, collapseProgress)
// ═══════════════════════════════════════════════════════════
@@ -679,101 +705,53 @@ private fun CollapsingProfileHeader(
.height(headerHeight)
) {
// ═══════════════════════════════════════════════════════════
// 🎨 BLURRED AVATAR BACKGROUND (вместо цвета)
// 🎨 BLURRED AVATAR BACKGROUND - только когда аватар уже круглый
// При квадратном аватаре фон не нужен (аватар сам занимает весь header)
// ═══════════════════════════════════════════════════════════
BlurredAvatarBackground(
publicKey = publicKey,
avatarRepository = avatarRepository,
fallbackColor = avatarColors.backgroundColor,
blurRadius = 25f,
alpha = 0.3f
)
// ═══════════════════════════════════════════════════════════
// 🔙 BACK BUTTON
// ═══════════════════════════════════════════════════════════
Box(
modifier = Modifier
.padding(top = statusBarHeight)
.padding(start = 4.dp, top = 4.dp)
.size(48.dp),
contentAlignment = Alignment.Center
) {
IconButton(
onClick = onBack,
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = TablerIcons.ArrowLeft,
contentDescription = "Back",
tint = if (isColorLight(avatarColors.backgroundColor)) Color.Black else Color.White,
modifier = Modifier.size(24.dp)
)
}
}
// ═══════════════════════════════════════════════════════════
// ⋮ MENU BUTTON (top right corner)
// ═══════════════════════════════════════════════════════════
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = statusBarHeight)
.padding(end = 4.dp, top = 4.dp)
.size(48.dp),
contentAlignment = Alignment.Center
) {
IconButton(
onClick = { onAvatarMenuChange(true) },
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = TablerIcons.DotsVertical,
contentDescription = "Profile menu",
tint = if (isColorLight(avatarColors.backgroundColor)) Color.Black else Color.White,
modifier = Modifier.size(24.dp)
)
}
// Меню для установки фото профиля
com.rosetta.messenger.ui.chats.components.ProfilePhotoMenu(
expanded = showAvatarMenu,
onDismiss = { onAvatarMenuChange(false) },
isDarkTheme = isDarkTheme,
onSetPhotoClick = {
onAvatarMenuChange(false)
onSetPhotoClick()
}
if (collapseProgress > 0.3f) {
BlurredAvatarBackground(
publicKey = publicKey,
avatarRepository = avatarRepository,
fallbackColor = avatarColors.backgroundColor,
blurRadius = 25f,
alpha = 0.3f * ((collapseProgress - 0.3f) / 0.7f).coerceIn(0f, 1f) // Плавное появление
)
}
// ═══════════════════════════════════════════════════════════
// 👤 AVATAR - shrinks and moves up (with real avatar support)
// <EFBFBD> AVATAR - По умолчанию квадратный, при скролле становится круглым
// РИСУЕМ ПЕРВЫМ чтобы кнопки были поверх
// ═══════════════════════════════════════════════════════════
if (avatarSize > 1.dp) {
Box(
modifier = Modifier
.offset(
x = avatarCenterX + (AVATAR_SIZE_EXPANDED - avatarSize) / 2,
x = avatarX,
y = avatarY
)
.size(avatarSize)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.15f))
.padding(2.dp)
.clip(CircleShape),
.width(avatarWidth)
.height(avatarHeight)
.clip(RoundedCornerShape(cornerRadius)),
contentAlignment = Alignment.Center
) {
// Используем AvatarImage если репозиторий доступен
if (avatarRepository != null) {
AvatarImage(
publicKey = publicKey,
avatarRepository = avatarRepository,
size = avatarSize - 4.dp,
isDarkTheme = false, // Header всегда светлый на цветном фоне
onClick = null,
showOnlineIndicator = false
)
// При collapseProgress < 0.2 - fullscreen аватар
if (collapseProgress < 0.2f) {
FullSizeAvatar(
publicKey = publicKey,
avatarRepository = avatarRepository
)
} else {
AvatarImage(
publicKey = publicKey,
avatarRepository = avatarRepository,
size = avatarSize - 4.dp,
isDarkTheme = false,
onClick = null,
showOnlineIndicator = false
)
}
} else {
// Fallback: цветной placeholder с инициалами
Box(
@@ -795,6 +773,64 @@ private fun CollapsingProfileHeader(
}
}
// ═══════════════════════════════════════════════════════════
// 🔙 BACK BUTTON (поверх аватара)
// ═══════════════════════════════════════════════════════════
Box(
modifier = Modifier
.padding(top = statusBarHeight)
.padding(start = 4.dp, top = 4.dp)
.size(48.dp),
contentAlignment = Alignment.Center
) {
IconButton(
onClick = onBack,
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = TablerIcons.ArrowLeft,
contentDescription = "Back",
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}
// ═══════════════════════════════════════════════════════════
// ⋮ MENU BUTTON (top right corner, поверх аватара)
// ═══════════════════════════════════════════════════════════
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = statusBarHeight)
.padding(end = 4.dp, top = 4.dp)
.size(48.dp),
contentAlignment = Alignment.Center
) {
IconButton(
onClick = { onAvatarMenuChange(true) },
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = TablerIcons.DotsVertical,
contentDescription = "Profile menu",
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
// Меню для установки фото профиля
com.rosetta.messenger.ui.chats.components.ProfilePhotoMenu(
expanded = showAvatarMenu,
onDismiss = { onAvatarMenuChange(false) },
isDarkTheme = isDarkTheme,
onSetPhotoClick = {
onAvatarMenuChange(false)
onSetPhotoClick()
}
)
}
// ═══════════════════════════════════════════════════════════
// 📝 TEXT BLOCK - Name + Online, always centered
// ═══════════════════════════════════════════════════════════
@@ -855,7 +891,55 @@ private fun CollapsingProfileHeader(
}
// ═════════════════════════════════════════════════════════════
// 📦 PROFILE CARD COMPONENT - Legacy (kept for OtherProfileScreen)
// <EFBFBD> FULL SIZE AVATAR - Fills entire container (for expanded state)
// ═════════════════════════════════════════════════════════════
@Composable
private fun FullSizeAvatar(
publicKey: String,
avatarRepository: AvatarRepository?
) {
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) }
var bitmap by remember(avatars) { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(avatars) {
bitmap = if (avatars.isNotEmpty()) {
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
com.rosetta.messenger.utils.AvatarFileManager.base64ToBitmap(avatars.first().base64Data)
}
} else {
null
}
}
if (bitmap != null) {
Image(
bitmap = bitmap!!.asImageBitmap(),
contentDescription = "Avatar",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
val avatarColors = getAvatarColor(publicKey, false)
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
)
}
}
}
// ═════════════════════════════════════════════════════════════
// <20>📦 PROFILE CARD COMPONENT - Legacy (kept for OtherProfileScreen)
// ═════════════════════════════════════════════════════════════
@Composable
fun ProfileCard(