Add new drawable resources for icons and themes
- Created `archive_filled.xml` for filled archive icon. - Added `bookmark_outlined.xml` for outlined bookmark icon. - Introduced `day_theme_filled.xml` for day theme icon. - Added `folder_outlined.xml` for outlined folder icon. - Created `gear_outlined.xml` for outlined gear icon. - Introduced `night_mode.xml` for night mode icon.
This commit is contained in:
@@ -12,6 +12,7 @@ import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
@@ -59,6 +60,7 @@ import com.rosetta.messenger.biometric.BiometricAvailability
|
||||
import com.rosetta.messenger.biometric.BiometricPreferences
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
|
||||
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlueDark
|
||||
|
||||
@@ -427,19 +429,21 @@ fun ProfileScreen(
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// TELEGRAM ARCHITECTURE: LazyColumn = RecyclerView
|
||||
// Item 0 = spacer высотой с expanded header (как Telegram item 0)
|
||||
// scrollOffset вычисляется из позиции скролла (как Telegram extraHeight)
|
||||
// Контент и хедер двигаются SYNC — один скролл двигает всё
|
||||
// Item 0 = spacer высотой maxScrollOffset (ровно сколько нужно проскроллить)
|
||||
// scrollOffset = firstVisibleItemScrollOffset (напрямую, без coerce)
|
||||
// LazyColumn имеет padding-top = collapsedHeight чтобы контент
|
||||
// не залезал под collapsed хедер.
|
||||
// Мёртвой зоны нет — каждый пиксель скролла двигает хедер.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
val listState = rememberLazyListState()
|
||||
val expandedHeaderDp = with(density) { expandedHeightPx.toDp() }
|
||||
val spacerHeightDp = with(density) { maxScrollOffset.toDp() }
|
||||
val collapsedHeightDp = with(density) { collapsedHeightPx.toDp() }
|
||||
|
||||
// Derive scrollOffset from LazyColumn scroll — как Telegram checkListViewScroll()
|
||||
// item 0 top position → extraHeight
|
||||
// scrollOffset напрямую из LazyColumn — как Telegram checkListViewScroll()
|
||||
val scrollOffset by remember {
|
||||
derivedStateOf {
|
||||
if (listState.firstVisibleItemIndex == 0) {
|
||||
listState.firstVisibleItemScrollOffset.toFloat().coerceAtMost(maxScrollOffset)
|
||||
listState.firstVisibleItemScrollOffset.toFloat()
|
||||
} else {
|
||||
maxScrollOffset
|
||||
}
|
||||
@@ -484,17 +488,13 @@ fun ProfileScreen(
|
||||
// isPulledDown имеет ВЫСШИЙ ПРИОРИТЕТ - игнорирует isDragging
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
val targetOverscroll = when {
|
||||
isPulledDown -> maxOverscroll // 🔥 ВЫСШИЙ ПРИОРИТЕТ: snap сработал - держим раскрытым!
|
||||
isDragging -> overscrollOffset // Во время drag (до порога) - следуем за пальцем
|
||||
overscrollOffset > snapThreshold -> maxOverscroll // Перешли порог - snap к max
|
||||
else -> 0f // Не дотянули - snap обратно
|
||||
isPulledDown -> maxOverscroll
|
||||
isDragging -> overscrollOffset
|
||||
overscrollOffset > snapThreshold -> maxOverscroll
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
// 🔥 FIX: Когда isPulledDown=true - анимация должна быть МГНОВЕННОЙ
|
||||
// чтобы аватарка сразу заполнилась после порога
|
||||
val currentProgress = (overscrollOffset / maxOverscroll).coerceIn(0f, 1f)
|
||||
|
||||
// Плавная spring анимация для snap (без bounce для гладкости)
|
||||
// Spring анимация для snap (без bounce)
|
||||
val animatedOverscroll by animateFloatAsState(
|
||||
targetValue = targetOverscroll,
|
||||
animationSpec = if (isDragging && !isPulledDown) {
|
||||
@@ -590,7 +590,7 @@ fun ProfileScreen(
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
// Overscroll при свайпе вниз от верха (когда LazyColumn в начале)
|
||||
if (available.y > 0 && scrollOffset == 0f) {
|
||||
if (available.y > 0 && !listState.canScrollBackward) {
|
||||
isDragging = true
|
||||
val resistance = if (isPulledDown) 1f else 0.5f
|
||||
val delta = available.y * resistance
|
||||
@@ -625,7 +625,8 @@ fun ProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// HEADER SNAP — как Telegram smoothScrollBy в ACTION_UP
|
||||
// HEADER SNAP — Telegram smoothScrollBy(dy, EASE_OUT_QUINT)
|
||||
// animateScrollBy = Compose эквивалент smoothScrollBy
|
||||
val currentOffset = scrollOffset
|
||||
if (currentOffset > 0f && currentOffset < maxScrollOffset) {
|
||||
val progress = currentOffset / maxScrollOffset
|
||||
@@ -635,14 +636,18 @@ fun ProfileScreen(
|
||||
progress >= 0.6f -> true
|
||||
else -> false
|
||||
}
|
||||
if (snapToCollapsed) {
|
||||
// Snap to collapsed — доскроллить spacer вверх
|
||||
listState.animateScrollToItem(0, maxScrollOffset.toInt())
|
||||
val snapDelta = if (snapToCollapsed) {
|
||||
maxScrollOffset - currentOffset // скролл вперёд = collapse
|
||||
} else {
|
||||
// Snap to expanded — вернуть spacer в начало
|
||||
listState.animateScrollToItem(0, 0)
|
||||
-currentOffset // скролл назад = expand
|
||||
}
|
||||
// Поглощаем velocity — LazyColumn не fling'ит
|
||||
listState.animateScrollBy(
|
||||
value = snapDelta,
|
||||
animationSpec = tween(
|
||||
durationMillis = 250,
|
||||
easing = CubicBezierEasing(0.25f, 1f, 0.5f, 1f)
|
||||
)
|
||||
)
|
||||
return available
|
||||
}
|
||||
|
||||
@@ -725,16 +730,16 @@ fun ProfileScreen(
|
||||
.nestedScroll(nestedScrollConnection)
|
||||
) {
|
||||
// Scrollable content — Telegram architecture:
|
||||
// Item 0 = spacer (как Telegram RecyclerView item 0)
|
||||
// Скролл LazyColumn двигает КОНТЕНТ + хедер вместе
|
||||
// Item 0 = spacer (ровно maxScrollOffset px) → каждый пиксель скролла двигает хедер
|
||||
// padding-top = collapsedHeightDp → контент не залезает под collapsed хедер
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(top = collapsedHeightDp)
|
||||
) {
|
||||
// Item 0: spacer высотой с раскрытый хедер
|
||||
// Когда скроллим вверх — spacer уходит, scrollOffset растёт
|
||||
// Item 0: spacer = ровно сколько нужно проскроллить для collapse
|
||||
item {
|
||||
Spacer(modifier = Modifier.fillMaxWidth().height(expandedHeaderDp))
|
||||
Spacer(modifier = Modifier.fillMaxWidth().height(spacerHeightDp))
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
@@ -961,6 +966,43 @@ fun ProfileScreen(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 📷 CAMERA BUTTON — at boundary between header and content
|
||||
// Positioned at bottom-right of header, half overlapping content area
|
||||
// Fades out when collapsed or when avatar is expanded
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val cameraButtonAlpha = (1f - collapseProgress * 2.5f).coerceIn(0f, 1f) *
|
||||
(1f - expansionProgress * 4f).coerceIn(0f, 1f)
|
||||
if (cameraButtonAlpha > 0.01f) {
|
||||
val cameraButtonSize = 52.dp
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(
|
||||
x = (-16).dp,
|
||||
y = headerHeight - cameraButtonSize / 2
|
||||
)
|
||||
.size(cameraButtonSize)
|
||||
.graphicsLayer { alpha = cameraButtonAlpha }
|
||||
.shadow(
|
||||
elevation = 4.dp,
|
||||
shape = CircleShape,
|
||||
clip = false
|
||||
)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White)
|
||||
.clickable { showPhotoPicker = true },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.CameraPlus,
|
||||
contentDescription = "Change avatar",
|
||||
tint = Color(0xFF8E8E93),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🖼️ Кастомный быстрый Photo Picker
|
||||
@@ -1169,46 +1211,24 @@ private fun CollapsingProfileHeader(
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// <EFBFBD> ADD/CHANGE AVATAR BUTTON — bottom-right of avatar circle
|
||||
// Fades out on collapse and expansion
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val cameraButtonAlpha = avatarAlpha * (1f - expandFraction * 4f).coerceIn(0f, 1f)
|
||||
if (cameraButtonAlpha > 0.01f) {
|
||||
val cameraButtonSize = 44.dp
|
||||
// Position: bottom-right of the avatar circle
|
||||
val avatarCenterXPos = screenWidth / 2
|
||||
val avatarCenterYPos = avatarY + avatarSize / 2
|
||||
// Offset to bottom-right edge of circle (45° from center)
|
||||
val offsetFromCenter = avatarSize / 2 * 0.7f // cos(45°) ≈ 0.707
|
||||
val cameraX = avatarCenterXPos + offsetFromCenter - cameraButtonSize / 2
|
||||
val cameraY = avatarCenterYPos + offsetFromCenter - cameraButtonSize / 2
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(x = cameraX, y = cameraY)
|
||||
.size(cameraButtonSize)
|
||||
.graphicsLayer { alpha = cameraButtonAlpha }
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF3A3A3C))
|
||||
.clickable { onSetPhotoClick() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.CameraPlus,
|
||||
contentDescription = "Change avatar",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// <20>🔙 BACK BUTTON
|
||||
// 🔙 BACK BUTTON - Aligned with text vertical center
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.padding(top = statusBarHeight)
|
||||
.padding(start = 4.dp, top = 4.dp)
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.offset(x = 4.dp, y = textY)
|
||||
.graphicsLayer {
|
||||
val centerOffsetY =
|
||||
with(density) {
|
||||
androidx.compose
|
||||
.ui
|
||||
.unit
|
||||
.lerp(24.dp, 18.dp, collapseProgress)
|
||||
.toPx()
|
||||
}
|
||||
translationY = -centerOffsetY
|
||||
}
|
||||
.size(48.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
@@ -1223,13 +1243,23 @@ private fun CollapsingProfileHeader(
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// ⋮ MENU BUTTON / 💾 SAVE BUTTON
|
||||
// ⋮ MENU BUTTON / 💾 SAVE BUTTON - Aligned with text vertical center
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.align(Alignment.TopEnd)
|
||||
.padding(top = statusBarHeight)
|
||||
.padding(end = 4.dp, top = 4.dp),
|
||||
.offset(x = -4.dp, y = textY)
|
||||
.graphicsLayer {
|
||||
val centerOffsetY =
|
||||
with(density) {
|
||||
androidx.compose
|
||||
.ui
|
||||
.unit
|
||||
.lerp(24.dp, 18.dp, collapseProgress)
|
||||
.toPx()
|
||||
}
|
||||
translationY = -centerOffsetY
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AnimatedVisibility(visible = hasChanges, enter = fadeIn(), exit = fadeOut()) {
|
||||
@@ -1750,30 +1780,35 @@ private fun TelegramToggleItem(
|
||||
}
|
||||
}
|
||||
|
||||
// Material 2 / old Telegram style switch
|
||||
// Telegram-style switch with outlined thumb
|
||||
val thumbOffset by animateFloatAsState(
|
||||
targetValue = if (isEnabled) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 150),
|
||||
animationSpec = tween(durationMillis = 200),
|
||||
label = "thumb"
|
||||
)
|
||||
val trackColor by animateColorAsState(
|
||||
targetValue = if (isEnabled) accentColor.copy(alpha = 0.5f)
|
||||
else if (isDarkTheme) Color(0xFF39393D) else Color(0xFFBDBDBD),
|
||||
animationSpec = tween(durationMillis = 150),
|
||||
targetValue = if (isEnabled) accentColor
|
||||
else if (isDarkTheme) Color(0xFF5A5A5E) else Color(0xFF999999),
|
||||
animationSpec = tween(durationMillis = 200),
|
||||
label = "track"
|
||||
)
|
||||
val thumbColor by animateColorAsState(
|
||||
val thumbBorderColor by animateColorAsState(
|
||||
targetValue = if (isEnabled) accentColor
|
||||
else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFFF1F1F1),
|
||||
animationSpec = tween(durationMillis = 150),
|
||||
label = "thumbColor"
|
||||
else if (isDarkTheme) Color(0xFF5A5A5E) else Color(0xFF999999),
|
||||
animationSpec = tween(durationMillis = 200),
|
||||
label = "thumbBorder"
|
||||
)
|
||||
|
||||
val trackWidth = 38.dp
|
||||
val trackHeight = 22.dp
|
||||
val thumbSize = 26.dp
|
||||
val borderWidth = 2.dp
|
||||
val thumbTravel = trackWidth - thumbSize + borderWidth
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(37.dp)
|
||||
.height(20.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(trackColor)
|
||||
.width(trackWidth)
|
||||
.height(thumbSize)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
@@ -1781,13 +1816,25 @@ private fun TelegramToggleItem(
|
||||
),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
// Track
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(x = (17.dp * thumbOffset))
|
||||
.size(20.dp)
|
||||
.shadow(2.dp, CircleShape)
|
||||
.width(trackWidth)
|
||||
.height(trackHeight)
|
||||
.clip(RoundedCornerShape(trackHeight / 2))
|
||||
.background(trackColor)
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
// Thumb with border
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(x = thumbTravel * thumbOffset)
|
||||
.size(thumbSize)
|
||||
.clip(CircleShape)
|
||||
.background(thumbColor)
|
||||
.background(thumbBorderColor)
|
||||
.padding(borderWidth)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user