feat: add animated badge for top-level requests count in ChatsListScreen

This commit is contained in:
2026-02-14 00:31:05 +05:00
parent e399fd04aa
commit a46968cfff
7 changed files with 242 additions and 83 deletions

View File

@@ -16,6 +16,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -237,7 +238,11 @@ fun SelectAccountScreen(
}
// Create Account Modal
if (showCreateModal) {
AnimatedVisibility(
visible = showCreateModal,
enter = fadeIn(animationSpec = tween(200)),
exit = fadeOut(animationSpec = tween(150))
) {
CreateAccountModal(
isDarkTheme = isDarkTheme,
onCreateNew = onCreateNew,
@@ -460,6 +465,16 @@ private fun CreateAccountModal(
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
// Анимация карточки: scale + fade с пружинным эффектом
val cardScale by animateFloatAsState(
targetValue = 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow
),
label = "cardScale"
)
Box(
modifier = Modifier
.fillMaxSize()
@@ -470,6 +485,10 @@ private fun CreateAccountModal(
Card(
modifier = Modifier
.padding(32.dp)
.graphicsLayer {
scaleX = cardScale
scaleY = cardScale
}
.clickable(enabled = false, onClick = {}),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = backgroundColor)

View File

@@ -494,33 +494,27 @@ fun ChatsListScreen(
)
val headerColor = avatarColors.backgroundColor
// Header с blur аватарки (fallback = голубой) или акцентным цветом (light)
// Header: avatar blur или цвет шапки chat list
Box(modifier = Modifier.fillMaxWidth()) {
if (isDarkTheme) {
if (backgroundBlurColorId == "solid_blue") {
// Голубой фон
Box(
modifier = Modifier
.matchParentSize()
.background(PrimaryBlueDark)
)
} else {
// Avatar blur (default)
if (backgroundBlurColorId == "avatar") {
// Avatar blur
BlurredAvatarBackground(
publicKey = accountPublicKey,
avatarRepository = avatarRepository,
fallbackColor = PrimaryBlueDark,
fallbackColor = if (isDarkTheme) Color(0xFF2A2A2A) else PrimaryBlue,
blurRadius = 40f,
alpha = 0.6f,
overlayColors = emptyList(),
overlayColors = null,
isDarkTheme = isDarkTheme
)
}
} else {
// None или любой другой — стандартный цвет шапки
Box(
modifier = Modifier
.matchParentSize()
.background(PrimaryBlue)
.background(
if (isDarkTheme) Color(0xFF2A2A2A) else PrimaryBlue
)
)
}
@@ -1102,16 +1096,48 @@ fun ChatsListScreen(
tint =
Color.White
)
if (topLevelRequestsCount > 0) {
Box(
modifier =
Modifier
.align(Alignment.TopEnd)
.offset(x = 4.dp, y = (-4).dp)
.size(10.dp)
.clip(CircleShape)
.background(if (isDarkTheme) Color.White else PrimaryBlue)
// Badge с числом запросов
androidx.compose.animation.AnimatedVisibility(
visible = topLevelRequestsCount > 0,
enter = scaleIn(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
) + fadeIn(),
exit = scaleOut() + fadeOut(),
modifier = Modifier.align(Alignment.TopEnd)
) {
val badgeText = remember(topLevelRequestsCount) {
when {
topLevelRequestsCount > 99 -> "99+"
else -> topLevelRequestsCount.toString()
}
}
val badgeBg = Color.White
val badgeShape = RoundedCornerShape(50)
Box(
modifier = Modifier
.offset(x = 6.dp, y = (-6).dp)
.defaultMinSize(minWidth = 20.dp, minHeight = 20.dp)
.clip(badgeShape)
.background(
color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4)
)
.padding(2.dp)
.clip(badgeShape)
.background(color = badgeBg),
contentAlignment = Alignment.Center
) {
Text(
text = badgeText,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF0D8CF4),
lineHeight = 10.sp,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
}
}
}
@@ -3314,6 +3340,7 @@ fun RequestsScreen(
onPin = { onTogglePin(request.opponentKey) }
)
if (request != requests.last()) {
Divider(
modifier = Modifier.padding(start = 84.dp),
color = dividerColor,
@@ -3323,6 +3350,7 @@ fun RequestsScreen(
}
}
}
}
dialogToDelete?.let { dialog ->
AlertDialog(

View File

@@ -42,34 +42,63 @@ fun BoxScope.BlurredAvatarBackground(
overlayColors: List<Color>? = null,
isDarkTheme: Boolean = true
) {
// В светлой теме: если дефолтный фон (avatar) — синий как шапка chat list,
// если выбран кастомный цвет в Appearance — используем его
if (!isDarkTheme) {
val lightBgModifier = if (overlayColors != null && overlayColors.isNotEmpty()) {
if (overlayColors.size == 1) {
Modifier.matchParentSize().background(overlayColors[0])
} else {
Modifier.matchParentSize().background(
Brush.linearGradient(colors = overlayColors)
)
}
} else {
Modifier.matchParentSize().background(Color(0xFF0D8CF4))
}
Box(modifier = lightBgModifier)
// В светлой теме с дефолтным фоном (avatar, без overlay) — синий как шапка chat list
if (!isDarkTheme && (overlayColors == null || overlayColors.isEmpty())) {
Box(modifier = Modifier.matchParentSize().background(Color(0xFF0D8CF4)))
return
}
// Если выбран цвет в Appearance — просто сплошной цвет/градиент, без blur
// Если выбран цвет в Appearance — рисуем блюр аватарки + полупрозрачный overlay поверх
// (одинаково для светлой и тёмной темы, чтобы цвет совпадал с превью в Appearance)
if (overlayColors != null && overlayColors.isNotEmpty()) {
val bgModifier = if (overlayColors.size == 1) {
Modifier.matchParentSize().background(overlayColors[0])
// Загружаем блюр аватарки для подложки
val avatarsForOverlay by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) }
val avatarKeyForOverlay = remember(avatarsForOverlay) {
avatarsForOverlay.firstOrNull()?.timestamp ?: 0L
}
var blurredForOverlay by remember { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(avatarKeyForOverlay) {
val currentAvatars = avatarsForOverlay
if (currentAvatars.isNotEmpty()) {
val original = withContext(Dispatchers.IO) {
AvatarFileManager.base64ToBitmap(currentAvatars.first().base64Data)
}
if (original != null) {
blurredForOverlay = withContext(Dispatchers.Default) {
val scaled = Bitmap.createScaledBitmap(original, original.width / 4, original.height / 4, true)
var result = scaled
repeat(2) { result = fastBlur(result, (blurRadius / 4).toInt().coerceAtLeast(1)) }
result
}
}
} else {
Modifier.matchParentSize().background(
Brush.linearGradient(colors = overlayColors)
blurredForOverlay = null
}
}
// Подложка: блюр аватарки или fallback цвет
Box(modifier = Modifier.matchParentSize()) {
if (blurredForOverlay != null) {
Image(
bitmap = blurredForOverlay!!.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize().graphicsLayer { this.alpha = 0.35f },
contentScale = ContentScale.Crop
)
}
Box(modifier = bgModifier)
}
// Overlay — полупрозрачный, как в Appearance preview
val overlayAlpha = if (blurredForOverlay != null) 0.55f else 0.85f
val overlayMod = if (overlayColors.size == 1) {
Modifier.matchParentSize().background(overlayColors[0].copy(alpha = overlayAlpha))
} else {
Modifier.matchParentSize().background(
Brush.linearGradient(colors = overlayColors.map { it.copy(alpha = overlayAlpha) })
)
}
Box(modifier = overlayMod)
return
}

View File

@@ -27,6 +27,13 @@ object BackgroundBlurPresets {
label = "Avatar"
)
/** Вариант "none" — стандартные цвета шапки без blur и без overlay */
val noneDefault = BackgroundBlurOption(
id = "none",
colors = emptyList(),
label = "None"
)
/** Сплошные цвета */
private val solidColors = listOf(
BackgroundBlurOption("solid_blue", listOf(Color(0xFF0D8CF4)), "Blue"),
@@ -54,19 +61,19 @@ object BackgroundBlurPresets {
/** Все варианты в порядке отображения: сначала сплошные, потом градиенты */
val all: List<BackgroundBlurOption> = solidColors + gradients
/** Все варианты включая "Avatar" (default) */
val allWithDefault: List<BackgroundBlurOption> = listOf(avatarDefault) + all
/** Все варианты включая "Avatar" и "None" */
val allWithDefault: List<BackgroundBlurOption> = listOf(noneDefault, avatarDefault) + all
/**
* Найти вариант по id. Возвращает [avatarDefault] если не найден.
*/
fun findById(id: String): BackgroundBlurOption {
return allWithDefault.find { it.id == id } ?: avatarDefault
return allWithDefault.find { it.id == id } ?: noneDefault
}
/**
* Получить список цветов для overlay по id.
* Возвращает null если id == "avatar" (значит используется blur аватарки без overlay).
* Возвращает null если id == "avatar" или "none" (без overlay).
*/
fun getOverlayColors(id: String): List<Color>? {
val option = findById(id)

View File

@@ -252,7 +252,14 @@ private fun ProfileBlurPreview(
// ═══════════════════════════════════════════════════
// LAYER 2: Color/gradient overlay (или fallback)
// ═══════════════════════════════════════════════════
if (overlayColors != null && overlayColors.isNotEmpty()) {
if (selectedId == "none") {
// None — стандартный цвет шапки
Box(
modifier = Modifier
.fillMaxSize()
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4))
)
} else if (overlayColors != null && overlayColors.isNotEmpty()) {
val overlayMod = if (overlayColors.size == 1) {
Modifier
.fillMaxSize()
@@ -475,6 +482,24 @@ private fun ColorCircleItem(
contentAlignment = Alignment.Center
) {
when {
option.id == "none" -> {
// None — стандартные цвета, перечеркнутый круг
Box(
modifier = Modifier
.fillMaxSize()
.background(
if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4)
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = TablerIcons.X,
contentDescription = "None",
tint = Color.White.copy(alpha = 0.9f),
modifier = Modifier.size(18.dp)
)
}
}
option.id == "avatar" -> {
Box(
modifier = Modifier
@@ -512,8 +537,8 @@ private fun ColorCircleItem(
}
}
// Галочка с затемнённым фоном (не для avatar — там уже иконка)
if (isSelected && option.id != "avatar") {
// Галочка с затемнённым фоном (не для avatar/none — там уже иконка)
if (isSelected && option.id != "avatar" && option.id != "none") {
Box(
modifier = Modifier
.fillMaxSize()

View File

@@ -577,19 +577,21 @@ fun OtherProfileScreen(
)
// ═══════════════════════════════════════════════════════════
// ✉️ WRITE MESSAGE BUTTON
// ✉️ WRITE MESSAGE + 📞 CALL BUTTONS
// ═══════════════════════════════════════════════════════════
Spacer(modifier = Modifier.height(12.dp))
Box(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
// Write Message
Button(
onClick = { onWriteMessage(user) },
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
@@ -609,12 +611,43 @@ fun OtherProfileScreen(
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Write Message",
fontSize = 16.sp,
text = "Message",
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
}
// Call
Button(
onClick = { /* TODO: call action */ },
modifier = Modifier
.weight(1f)
.height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFE8E8ED),
contentColor = if (isDarkTheme) Color.White else Color.Black
),
elevation = ButtonDefaults.buttonElevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp
)
) {
Icon(
imageVector = TablerIcons.Phone,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (isDarkTheme) Color.White else PrimaryBlue
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Call",
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = if (isDarkTheme) Color.White else Color.Black
)
}
}
Spacer(modifier = Modifier.height(12.dp))
@@ -662,7 +695,8 @@ fun OtherProfileScreen(
state = pagerState,
modifier = Modifier.fillMaxWidth().heightIn(min = sharedPagerMinHeight),
beyondBoundsPageCount = 0,
verticalAlignment = Alignment.Top
verticalAlignment = Alignment.Top,
userScrollEnabled = false
) { page ->
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.TopStart) {
OtherProfileSharedTabContent(

View File

@@ -569,6 +569,14 @@ fun ProfileScreen(
}
}
// Коллапс при удалении аватара — сразу сворачиваем
LaunchedEffect(hasAvatar) {
if (!hasAvatar) {
isPulledDown = false
overscrollOffset = 0f
}
}
// ═══════════════════════════════════════════════════════════════
// NESTED SCROLL — overscroll (pull-down аватарка) + header snap
// Header collapse управляется скроллом LazyColumn (Telegram RecyclerView)
@@ -605,8 +613,8 @@ fun ProfileScreen(
available: Offset,
source: NestedScrollSource
): Offset {
// Overscroll при свайпе вниз от верха (когда LazyColumn в начале)
if (available.y > 0 && !listState.canScrollBackward) {
// Overscroll при свайпе вниз от верха (только если есть аватар)
if (available.y > 0 && !listState.canScrollBackward && hasAvatar) {
isDragging = true
val resistance = if (isPulledDown) 1f else 0.5f
val delta = available.y * resistance
@@ -1126,6 +1134,14 @@ private fun CollapsingProfileHeader(
// и естественно перекрывает его. Без мерцания.
// ═══════════════════════════════════════════════════════════
Box(modifier = Modifier.matchParentSize()) {
if (backgroundBlurColorId == "none") {
// None — стандартный цвет шапки без blur
Box(
modifier = Modifier
.fillMaxSize()
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4))
)
} else {
BlurredAvatarBackground(
publicKey = publicKey,
avatarRepository = avatarRepository,
@@ -1136,6 +1152,7 @@ private fun CollapsingProfileHeader(
isDarkTheme = isDarkTheme
)
}
}
// ═══════════════════════════════════════════════════════════
// 🌅 BOTTOM GRADIENT — плавно исчезает только когда аватарка