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