Implement feature X to enhance user experience and optimize performance
This commit is contained in:
@@ -291,203 +291,119 @@ fun ProfileScreen(
|
||||
// ViewModel state
|
||||
val profileState by viewModel.state.collectAsState()
|
||||
|
||||
// Scroll state for collapsing header animation
|
||||
// Scroll state for collapsing header + overscroll avatar expansion
|
||||
val density = LocalDensity.current
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
// Header heights
|
||||
val expandedHeightPx = with(density) { (EXPANDED_HEADER_HEIGHT + statusBarHeight).toPx() }
|
||||
val collapsedHeightPx = with(density) { (COLLAPSED_HEADER_HEIGHT + statusBarHeight).toPx() }
|
||||
|
||||
// Track scroll offset with animated state for smooth transitions
|
||||
// Может быть отрицательным для overscroll (оттягивание вверх)
|
||||
var scrollOffset by remember { mutableFloatStateOf(0f) }
|
||||
val maxScrollOffset = expandedHeightPx - collapsedHeightPx
|
||||
val minScrollOffset = -expandedHeightPx * 0.5f // Максимальный overscroll (50% высоты)
|
||||
|
||||
// Порог для snap - если overscroll > 50%, то snap к квадрату, иначе к кругу
|
||||
val snapThreshold = minScrollOffset * 0.5f
|
||||
// Track scroll offset for collapsing (скролл вверх = collapse)
|
||||
var scrollOffset by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
// Анимированный overscroll offset для плавного snap
|
||||
val animatedScrollOffset = remember { androidx.compose.animation.core.Animatable(0f) }
|
||||
// Calculate collapse progress (0 = expanded, 1 = collapsed)
|
||||
val collapseProgress by remember {
|
||||
derivedStateOf { (scrollOffset / maxScrollOffset).coerceIn(0f, 1f) }
|
||||
}
|
||||
|
||||
// Отслеживаем активный drag
|
||||
// Dynamic header height based on scroll
|
||||
val headerHeight =
|
||||
with(density) {
|
||||
(expandedHeightPx - scrollOffset).coerceAtLeast(collapsedHeightPx).toDp()
|
||||
}
|
||||
|
||||
// Track overscroll offset for avatar expansion (скролл вниз при достижении верха)
|
||||
var overscrollOffset by remember { mutableFloatStateOf(0f) }
|
||||
val maxOverscroll = with(density) { 200.dp.toPx() }
|
||||
val snapThreshold = maxOverscroll * 0.5f
|
||||
|
||||
// Track if user is currently dragging
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
|
||||
// Отслеживаем направление последнего скролла: true = вверх (к кругу), false = вниз (к квадрату)
|
||||
var lastScrollDirectionUp by remember { mutableStateOf(false) }
|
||||
|
||||
// Calculate collapse progress (0 = expanded, 1 = collapsed, negative = overscroll)
|
||||
val collapseProgress by remember {
|
||||
derivedStateOf { (animatedScrollOffset.value / maxScrollOffset).coerceIn(-1f, 1f) }
|
||||
}
|
||||
|
||||
// Определяем целевое состояние: true = круг (scrollOffset >= 0), false = квадрат (scrollOffset
|
||||
// < snapThreshold)
|
||||
val targetIsCircle by remember {
|
||||
// Целевое значение для snap: либо 0 (круг), либо maxOverscroll (квадрат)
|
||||
val snapTarget by remember {
|
||||
derivedStateOf {
|
||||
// Решение: круг если scrollOffset >= snapThreshold (50% от минимального)
|
||||
scrollOffset >= snapThreshold
|
||||
if (!isDragging && overscrollOffset > snapThreshold) maxOverscroll
|
||||
else if (!isDragging) 0f else overscrollOffset
|
||||
}
|
||||
}
|
||||
|
||||
// Текущее отображаемое состояние (для предотвращения мерцания)
|
||||
var displayedIsCircle by remember { mutableStateOf(true) }
|
||||
|
||||
// Overscroll progress - просто на основе scrollOffset
|
||||
val overscrollProgress by remember {
|
||||
derivedStateOf {
|
||||
// Если мы в режиме "круг", всегда показываем 0 (полный круг)
|
||||
// Если в режиме "квадрат", показываем 1 (полный квадрат)
|
||||
val rawProgress =
|
||||
if (animatedScrollOffset.value < 0f) {
|
||||
(-animatedScrollOffset.value / (-minScrollOffset)).coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
||||
// Дискретный прогресс на основе displayedIsCircle
|
||||
val discreteProgress = if (displayedIsCircle) 0f else 1f
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"📊 overscrollProgress: raw=$rawProgress, discrete=$discreteProgress, displayedIsCircle=$displayedIsCircle, targetIsCircle=$targetIsCircle, scrollOffset=${animatedScrollOffset.value}"
|
||||
)
|
||||
discreteProgress
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем displayedIsCircle только при отпускании пальца (snap)
|
||||
LaunchedEffect(isDragging, targetIsCircle) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"🎯 LaunchedEffect: isDragging=$isDragging, targetIsCircle=$targetIsCircle, displayedIsCircle=$displayedIsCircle, scrollOffset=$scrollOffset, lastScrollDirectionUp=$lastScrollDirectionUp"
|
||||
)
|
||||
|
||||
if (!isDragging) {
|
||||
// Палец отпущен - snap на основе направления скролла
|
||||
// Если скроллили ВВЕРХ - snap к кругу, если ВНИЗ - snap к квадрату
|
||||
val newIsCircle = lastScrollDirectionUp || scrollOffset >= 0f
|
||||
Log.d(
|
||||
TAG,
|
||||
"👆 Finger released: scrollOffset=$scrollOffset, lastScrollDirectionUp=$lastScrollDirectionUp, newIsCircle=$newIsCircle"
|
||||
// Пружинистая анимация для snap-эффекта - быстрая
|
||||
val animatedOverscroll by
|
||||
animateFloatAsState(
|
||||
targetValue = snapTarget,
|
||||
animationSpec =
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = 2000f // Очень быстрая анимация
|
||||
),
|
||||
label = "overscroll"
|
||||
)
|
||||
|
||||
if (scrollOffset < 0f) {
|
||||
// Были в overscroll зоне
|
||||
val targetOffset = if (newIsCircle) 0f else minScrollOffset
|
||||
Log.d(
|
||||
TAG,
|
||||
"🎯 Snap animation: from $scrollOffset to $targetOffset (circle=$newIsCircle)"
|
||||
)
|
||||
|
||||
// Сначала обновляем состояние формы
|
||||
displayedIsCircle = newIsCircle
|
||||
|
||||
// Потом анимируем позицию
|
||||
animatedScrollOffset.animateTo(
|
||||
targetValue = targetOffset,
|
||||
animationSpec =
|
||||
spring(
|
||||
dampingRatio =
|
||||
androidx.compose.animation.core.Spring
|
||||
.DampingRatioMediumBouncy,
|
||||
stiffness =
|
||||
androidx.compose.animation.core.Spring.StiffnessLow
|
||||
)
|
||||
)
|
||||
scrollOffset = targetOffset
|
||||
} else if (scrollOffset >= 0f) {
|
||||
// Обычный скролл - всегда круг
|
||||
displayedIsCircle = true
|
||||
animatedScrollOffset.snapTo(scrollOffset)
|
||||
}
|
||||
// Calculate expansion progress (0 = круг, 1 = квадрат) - только когда header раскрыт
|
||||
val expansionProgress by remember {
|
||||
derivedStateOf {
|
||||
if (collapseProgress > 0.1f) 0f // Не расширяем если header коллапсирован
|
||||
else (animatedOverscroll / maxOverscroll).coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
|
||||
// Синхронизация scrollOffset -> animatedScrollOffset во время drag
|
||||
LaunchedEffect(scrollOffset, isDragging) {
|
||||
if (isDragging) {
|
||||
animatedScrollOffset.snapTo(scrollOffset)
|
||||
Log.d(TAG, "🔄 Sync during drag: scrollOffset=$scrollOffset -> animatedScrollOffset")
|
||||
}
|
||||
}
|
||||
|
||||
// Nested scroll connection for tracking scroll with overscroll support
|
||||
// Nested scroll connection для collapsing + overscroll
|
||||
val nestedScrollConnection = remember {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
val delta = available.y
|
||||
val newOffset = scrollOffset - delta
|
||||
|
||||
// Отслеживаем, что палец активен
|
||||
val wasDragging = isDragging
|
||||
isDragging = true
|
||||
|
||||
// Отслеживаем направление скролла (только значимые движения)
|
||||
if (kotlin.math.abs(delta) > 3f) {
|
||||
// delta < 0 = палец идёт вверх (скролл контента вниз, возврат к кругу)
|
||||
// delta > 0 = палец идёт вниз (скролл контента вверх, к квадрату)
|
||||
lastScrollDirectionUp = delta < 0f
|
||||
Log.d(
|
||||
TAG,
|
||||
"🧭 Direction changed: delta=$delta, lastScrollDirectionUp=$lastScrollDirectionUp"
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"🔄 onPreScroll: delta=$delta, scrollOffset=$scrollOffset, newOffset=$newOffset, wasDragging=$wasDragging, displayedIsCircle=$displayedIsCircle, lastScrollDirectionUp=$lastScrollDirectionUp"
|
||||
)
|
||||
|
||||
// Если скроллим вверх (delta < 0) и были в overscroll зоне - возвращаемся к кругу
|
||||
if (delta < 0f && scrollOffset < 0f) {
|
||||
// Плавно уменьшаем overscroll
|
||||
val newValue = (scrollOffset - delta).coerceAtMost(0f)
|
||||
scrollOffset = newValue
|
||||
|
||||
// Когда достигли 0, переключаемся на круг
|
||||
if (scrollOffset >= 0f) {
|
||||
Log.d(TAG, "⚡ Reached 0 from overscroll - switching to circle")
|
||||
displayedIsCircle = true
|
||||
scrollOffset = 0f
|
||||
// Если тянем вверх (delta < 0)
|
||||
if (delta < 0) {
|
||||
// Сначала убираем overscroll если есть
|
||||
if (overscrollOffset > 0) {
|
||||
val newOffset = (overscrollOffset + delta).coerceAtLeast(0f)
|
||||
val consumed = overscrollOffset - newOffset
|
||||
overscrollOffset = newOffset
|
||||
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)
|
||||
}
|
||||
|
||||
Log.d(TAG, "📤 Scroll up in overscroll: scrollOffset=$scrollOffset")
|
||||
return Offset(0f, -delta)
|
||||
}
|
||||
|
||||
val consumed =
|
||||
when {
|
||||
// Scroll up (collapse) - delta < 0 = палец идёт вверх
|
||||
delta < 0 && scrollOffset < maxScrollOffset -> {
|
||||
val consumed =
|
||||
(newOffset.coerceIn(minScrollOffset, maxScrollOffset) -
|
||||
scrollOffset)
|
||||
scrollOffset = newOffset.coerceIn(minScrollOffset, maxScrollOffset)
|
||||
Log.d(TAG, "📈 Collapse: scrollOffset=$scrollOffset")
|
||||
-consumed
|
||||
}
|
||||
// Scroll down (expand / overscroll) - delta > 0 = палец идёт вниз
|
||||
delta > 0 && scrollOffset > minScrollOffset -> {
|
||||
val consumed =
|
||||
scrollOffset -
|
||||
newOffset.coerceIn(minScrollOffset, maxScrollOffset)
|
||||
scrollOffset = newOffset.coerceIn(minScrollOffset, maxScrollOffset)
|
||||
Log.d(TAG, "📉 Expand/Overscroll: scrollOffset=$scrollOffset")
|
||||
consumed
|
||||
}
|
||||
else -> 0f
|
||||
}
|
||||
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 {
|
||||
// Если достигли верха (scrollOffset = 0) и тянем вниз - создаем overscroll
|
||||
if (available.y > 0 && scrollOffset == 0f) {
|
||||
val resistance = 0.3f
|
||||
val delta = available.y * resistance
|
||||
overscrollOffset = (overscrollOffset + delta).coerceAtMost(maxOverscroll)
|
||||
return Offset(0f, available.y)
|
||||
}
|
||||
return Offset.Zero
|
||||
}
|
||||
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
Log.d(
|
||||
TAG,
|
||||
"👆 onPostFling: scrollOffset=$scrollOffset, displayedIsCircle=$displayedIsCircle, lastScrollDirectionUp=$lastScrollDirectionUp"
|
||||
)
|
||||
isDragging = false
|
||||
Log.d(TAG, "👆 onPostFling: drag ended, isDragging=false, triggering snap")
|
||||
return super.onPostFling(consumed, available)
|
||||
return Velocity.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -541,21 +457,7 @@ fun ProfileScreen(
|
||||
.nestedScroll(nestedScrollConnection)
|
||||
) {
|
||||
// Scrollable content
|
||||
LazyColumn(
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.padding(
|
||||
top =
|
||||
with(density) {
|
||||
// Не увеличиваем padding при overscroll
|
||||
// Используем animatedScrollOffset для плавности
|
||||
(expandedHeightPx -
|
||||
animatedScrollOffset.value
|
||||
.coerceAtLeast(0f))
|
||||
.toDp()
|
||||
}
|
||||
)
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(top = headerHeight)) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
@@ -666,7 +568,7 @@ fun ProfileScreen(
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
// 🎨 COLLAPSING HEADER - Telegram style
|
||||
// 🎨 COLLAPSING PROFILE HEADER with overscroll expansion
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
CollapsingProfileHeader(
|
||||
name = editedName.ifBlank { accountPublicKey.take(10) },
|
||||
@@ -674,7 +576,7 @@ fun ProfileScreen(
|
||||
publicKey = accountPublicKey,
|
||||
avatarColors = avatarColors,
|
||||
collapseProgress = collapseProgress,
|
||||
overscrollProgress = overscrollProgress,
|
||||
expansionProgress = expansionProgress,
|
||||
onBack = onBack,
|
||||
hasChanges = hasChanges,
|
||||
onSave = {
|
||||
@@ -803,7 +705,10 @@ fun ProfileScreen(
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
// 🎯 COLLAPSING PROFILE HEADER - Telegram Style Animation
|
||||
// 🎯 COLLAPSING PROFILE HEADER - Circle by default, Square on overscroll
|
||||
// По умолчанию: круглая аватарка
|
||||
// При overscroll вниз (expansionProgress): расширяется до квадрата
|
||||
// При скролле вверх (collapseProgress): уменьшается и уходит вверх
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
@Composable
|
||||
private fun CollapsingProfileHeader(
|
||||
@@ -812,7 +717,7 @@ private fun CollapsingProfileHeader(
|
||||
publicKey: String,
|
||||
avatarColors: AvatarColors,
|
||||
collapseProgress: Float,
|
||||
overscrollProgress: Float, // 0 = normal, 1 = full overscroll (квадратный аватар)
|
||||
expansionProgress: Float,
|
||||
onBack: () -> Unit,
|
||||
hasChanges: Boolean,
|
||||
onSave: () -> Unit,
|
||||
@@ -831,140 +736,118 @@ private fun CollapsingProfileHeader(
|
||||
// Get actual status bar height
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
// Header heights
|
||||
// По умолчанию header = ширина экрана минус отступ для Account, при скролле уменьшается
|
||||
val expandedHeight =
|
||||
screenWidthDp - 60.dp // Высота header (меньше чтобы не перекрывать Account)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 📐 HEADER HEIGHT - ФИКСИРОВАННАЯ! Не меняется при overscroll
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val expandedHeight = EXPANDED_HEADER_HEIGHT + statusBarHeight
|
||||
val collapsedHeight = COLLAPSED_HEADER_HEIGHT + statusBarHeight
|
||||
|
||||
// Animated header height - НЕ увеличивается при overscroll
|
||||
// Header height меняется только при collapse, НЕ при overscroll
|
||||
val headerHeight =
|
||||
androidx.compose.ui.unit.lerp(
|
||||
expandedHeight,
|
||||
collapsedHeight,
|
||||
collapseProgress.coerceAtLeast(0f)
|
||||
)
|
||||
androidx.compose.ui.unit.lerp(expandedHeight, collapsedHeight, collapseProgress)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 👤 AVATAR - По умолчанию круглый, при overscroll становится квадратным
|
||||
// Аватар всегда ограничен размером header
|
||||
// 👤 AVATAR - По умолчанию КРУГЛАЯ, при overscroll расширяется до прямоугольника
|
||||
// При collapse - уменьшается и уходит вверх
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// Размер круглого аватара по умолчанию
|
||||
val circleSize = AVATAR_SIZE_EXPANDED
|
||||
// Зона аватарки = ВСЯ высота header включая статус бар
|
||||
val avatarZoneHeight = EXPANDED_HEADER_HEIGHT + statusBarHeight
|
||||
|
||||
// При overscroll: от круга до полного размера header
|
||||
// При collapse: от круга до 0
|
||||
// При overscroll расширяем до прямоугольника на всю зону (только если не collapsed)
|
||||
val avatarWidth: Dp
|
||||
val avatarHeight: Dp
|
||||
val avatarX: Dp
|
||||
val avatarY: Dp
|
||||
val cornerRadius: Dp
|
||||
|
||||
val collapseOnly = collapseProgress.coerceAtLeast(0f)
|
||||
|
||||
if (overscrollProgress > 0f) {
|
||||
// OVERSCROLL: размер СРАЗУ полный, закругление плавно уменьшается
|
||||
avatarWidth = screenWidthDp
|
||||
avatarHeight = expandedHeight
|
||||
avatarX = 0.dp
|
||||
avatarY = 0.dp
|
||||
// Закругление плавно от круга (circleSize/2) до квадрата (0)
|
||||
cornerRadius = androidx.compose.ui.unit.lerp(circleSize / 2, 0.dp, overscrollProgress)
|
||||
if (collapseProgress < 0.1f && expansionProgress > 0f) {
|
||||
// Overscroll: круг -> прямоугольник на всю зону ВКЛЮЧАЯ статус бар
|
||||
avatarWidth = androidx.compose.ui.unit.lerp(circleSize, screenWidthDp, expansionProgress)
|
||||
avatarHeight =
|
||||
androidx.compose.ui.unit.lerp(circleSize, avatarZoneHeight, expansionProgress)
|
||||
} else {
|
||||
// NORMAL / COLLAPSE: круглый аватар уменьшается при скролле
|
||||
avatarWidth = androidx.compose.ui.unit.lerp(circleSize, 0.dp, collapseOnly)
|
||||
avatarHeight = androidx.compose.ui.unit.lerp(circleSize, 0.dp, collapseOnly)
|
||||
avatarX = (screenWidthDp - avatarWidth) / 2
|
||||
|
||||
// Позиция Y: по центру header при развернутом, уходит вверх при сворачивании
|
||||
val avatarCenterY = (expandedHeight - circleSize) / 2
|
||||
avatarY =
|
||||
if (collapseOnly < 0.5f) {
|
||||
androidx.compose.ui.unit.lerp(
|
||||
avatarCenterY,
|
||||
statusBarHeight + 14.dp,
|
||||
collapseOnly * 2
|
||||
)
|
||||
} else {
|
||||
val phase2Progress = (collapseOnly - 0.5f) / 0.5f
|
||||
androidx.compose.ui.unit.lerp(
|
||||
statusBarHeight + 14.dp,
|
||||
statusBarHeight - 60.dp,
|
||||
phase2Progress
|
||||
)
|
||||
}
|
||||
// Полностью круглый
|
||||
cornerRadius = circleSize / 2
|
||||
// Collapse: сразу начинаем уменьшаться от круга до 0
|
||||
val collapsedSize = androidx.compose.ui.unit.lerp(circleSize, 0.dp, collapseProgress)
|
||||
avatarWidth = collapsedSize
|
||||
avatarHeight = collapsedSize
|
||||
}
|
||||
|
||||
// Для cornerRadius используем меньшую сторону
|
||||
val avatarSize = minOf(avatarWidth, avatarHeight)
|
||||
val avatarSize = if (avatarWidth < avatarHeight) avatarWidth else avatarHeight
|
||||
|
||||
val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 0.sp, collapseOnly)
|
||||
// Позиция X: всегда по центру
|
||||
val avatarX = (screenWidthDp - avatarWidth) / 2
|
||||
|
||||
// Позиция Y
|
||||
val availableHeight = avatarZoneHeight - statusBarHeight
|
||||
val defaultCenterY = statusBarHeight + (availableHeight - avatarHeight) / 2
|
||||
val topAvatarY = 0.dp // От самого верха экрана при полном expansion
|
||||
|
||||
val avatarY =
|
||||
if (collapseProgress < 0.1f && expansionProgress > 0f) {
|
||||
// При overscroll прижимаемся к самому верху
|
||||
androidx.compose.ui.unit.lerp(defaultCenterY, topAvatarY, expansionProgress)
|
||||
} else {
|
||||
// Collapse: сразу начинаем уходить вверх
|
||||
androidx.compose.ui.unit.lerp(
|
||||
defaultCenterY,
|
||||
statusBarHeight - 80.dp,
|
||||
collapseProgress
|
||||
)
|
||||
}
|
||||
|
||||
// Закругление: круг по умолчанию, при overscroll становится квадратом
|
||||
val cornerRadius =
|
||||
if (collapseProgress < 0.1f && expansionProgress > 0f) {
|
||||
// Overscroll: круг -> квадрат с небольшим скруглением
|
||||
androidx.compose.ui.unit.lerp(avatarSize / 2, 12.dp, expansionProgress)
|
||||
} else {
|
||||
// Всегда круг
|
||||
avatarSize / 2
|
||||
}
|
||||
|
||||
val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 0.sp, collapseProgress)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 📝 TEXT - always centered, under avatar
|
||||
// 📝 TEXT - внизу header зоны, внутри блока
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val textX = screenWidthDp / 2 // Always center
|
||||
|
||||
// Позиция Y аватара для расчета текста (используем expandedHeight для стабильности)
|
||||
val avatarCenterYForText = (expandedHeight - circleSize) / 2
|
||||
|
||||
// Позиция текста: под аватаром по центру
|
||||
val avatarBottomY = avatarCenterYForText + circleSize // Низ аватара
|
||||
val textExpandedY = avatarBottomY + 16.dp // 16dp отступ от аватара
|
||||
val textDefaultY = expandedHeight - 60.dp // Внизу header блока (чуть ниже)
|
||||
val textCollapsedY = statusBarHeight + COLLAPSED_HEADER_HEIGHT / 2
|
||||
val textY = androidx.compose.ui.unit.lerp(textExpandedY, textCollapsedY, collapseOnly)
|
||||
|
||||
// Текст меняет позицию только при collapse, НЕ при overscroll
|
||||
val textY = androidx.compose.ui.unit.lerp(textDefaultY, textCollapsedY, collapseProgress)
|
||||
|
||||
// Font sizes
|
||||
val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseOnly)
|
||||
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseOnly)
|
||||
val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress)
|
||||
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.height(headerHeight)
|
||||
.clip(
|
||||
RoundedCornerShape(0.dp)
|
||||
) // Обрезаем содержимое по границам header
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth().height(headerHeight)) {
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🎨 BLURRED AVATAR BACKGROUND - скрываем при overscroll (аватар сам закрывает фон)
|
||||
// 🎨 BLURRED AVATAR BACKGROUND - всегда показываем
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
if (overscrollProgress < 0.5f) {
|
||||
BlurredAvatarBackground(
|
||||
publicKey = publicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
fallbackColor = avatarColors.backgroundColor,
|
||||
blurRadius = 25f,
|
||||
alpha = 0.3f
|
||||
)
|
||||
}
|
||||
BlurredAvatarBackground(
|
||||
publicKey = publicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
fallbackColor = avatarColors.backgroundColor,
|
||||
blurRadius = 25f,
|
||||
alpha = 0.3f
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 👤 AVATAR - По умолчанию круглый по центру
|
||||
// РИСУЕМ ПЕРВЫМ чтобы кнопки были поверх
|
||||
// 👤 AVATAR - Круг по умолчанию, квадрат при overscroll
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
if (avatarSize > 1.dp) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.offset(x = avatarX, y = avatarY)
|
||||
.width(avatarWidth)
|
||||
.height(avatarHeight)
|
||||
.size(width = avatarWidth, height = avatarHeight)
|
||||
.clip(RoundedCornerShape(cornerRadius)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// Используем AvatarImage если репозиторий доступен
|
||||
if (avatarRepository != null) {
|
||||
// Всегда используем FullSizeAvatar чтобы избежать мерцания при переключении
|
||||
FullSizeAvatar(
|
||||
publicKey = publicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
} else {
|
||||
// Fallback: цветной placeholder с инициалами
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxSize().background(avatarColors.backgroundColor),
|
||||
@@ -984,7 +867,7 @@ private fun CollapsingProfileHeader(
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🔙 BACK BUTTON (поверх аватара)
|
||||
// 🔙 BACK BUTTON
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
Box(
|
||||
modifier =
|
||||
@@ -1004,8 +887,7 @@ private fun CollapsingProfileHeader(
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// ⋮ MENU BUTTON / 💾 SAVE BUTTON (top right corner)
|
||||
// Показываем Save если есть изменения, иначе три точки меню
|
||||
// ⋮ MENU BUTTON / 💾 SAVE BUTTON
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
Box(
|
||||
modifier =
|
||||
@@ -1014,7 +896,6 @@ private fun CollapsingProfileHeader(
|
||||
.padding(end = 4.dp, top = 4.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// Save button (when has changes)
|
||||
AnimatedVisibility(visible = hasChanges, enter = fadeIn(), exit = fadeOut()) {
|
||||
TextButton(onClick = onSave) {
|
||||
Text(
|
||||
@@ -1025,7 +906,6 @@ private fun CollapsingProfileHeader(
|
||||
}
|
||||
}
|
||||
|
||||
// Menu button (when no changes)
|
||||
AnimatedVisibility(visible = !hasChanges, enter = fadeIn(), exit = fadeOut()) {
|
||||
IconButton(
|
||||
onClick = { onAvatarMenuChange(true) },
|
||||
@@ -1034,13 +914,12 @@ private fun CollapsingProfileHeader(
|
||||
Icon(
|
||||
imageVector = TablerIcons.DotsVertical,
|
||||
contentDescription = "Profile menu",
|
||||
tint = Color.White, // Всегда белые - на фоне аватара
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Меню для установки фото профиля
|
||||
com.rosetta.messenger.ui.chats.components.ProfilePhotoMenu(
|
||||
expanded = showAvatarMenu,
|
||||
onDismiss = { onAvatarMenuChange(false) },
|
||||
@@ -1058,7 +937,7 @@ private fun CollapsingProfileHeader(
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 📝 TEXT BLOCK - Name + Online, always centered
|
||||
// 📝 TEXT BLOCK - Name + Online
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
Column(
|
||||
modifier =
|
||||
@@ -1090,7 +969,6 @@ private fun CollapsingProfileHeader(
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
// Online text - always centered
|
||||
Text(text = "online", fontSize = onlineFontSize, color = Color(0xFF4CAF50))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user