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.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -237,7 +238,11 @@ fun SelectAccountScreen(
} }
// Create Account Modal // Create Account Modal
if (showCreateModal) { AnimatedVisibility(
visible = showCreateModal,
enter = fadeIn(animationSpec = tween(200)),
exit = fadeOut(animationSpec = tween(150))
) {
CreateAccountModal( CreateAccountModal(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onCreateNew = onCreateNew, onCreateNew = onCreateNew,
@@ -459,7 +464,17 @@ private fun CreateAccountModal(
val backgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -470,6 +485,10 @@ private fun CreateAccountModal(
Card( Card(
modifier = Modifier modifier = Modifier
.padding(32.dp) .padding(32.dp)
.graphicsLayer {
scaleX = cardScale
scaleY = cardScale
}
.clickable(enabled = false, onClick = {}), .clickable(enabled = false, onClick = {}),
shape = RoundedCornerShape(20.dp), shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = backgroundColor) colors = CardDefaults.cardColors(containerColor = backgroundColor)

View File

@@ -494,33 +494,27 @@ fun ChatsListScreen(
) )
val headerColor = avatarColors.backgroundColor val headerColor = avatarColors.backgroundColor
// Header с blur аватарки (fallback = голубой) или акцентным цветом (light) // Header: avatar blur или цвет шапки chat list
Box(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) {
if (isDarkTheme) { if (backgroundBlurColorId == "avatar") {
if (backgroundBlurColorId == "solid_blue") { // Avatar blur
// Голубой фон BlurredAvatarBackground(
Box( publicKey = accountPublicKey,
modifier = Modifier avatarRepository = avatarRepository,
.matchParentSize() fallbackColor = if (isDarkTheme) Color(0xFF2A2A2A) else PrimaryBlue,
.background(PrimaryBlueDark) blurRadius = 40f,
) alpha = 0.6f,
} else { overlayColors = null,
// Avatar blur (default) isDarkTheme = isDarkTheme
BlurredAvatarBackground( )
publicKey = accountPublicKey,
avatarRepository = avatarRepository,
fallbackColor = PrimaryBlueDark,
blurRadius = 40f,
alpha = 0.6f,
overlayColors = emptyList(),
isDarkTheme = isDarkTheme
)
}
} else { } else {
// None или любой другой — стандартный цвет шапки
Box( Box(
modifier = Modifier modifier = Modifier
.matchParentSize() .matchParentSize()
.background(PrimaryBlue) .background(
if (isDarkTheme) Color(0xFF2A2A2A) else PrimaryBlue
)
) )
} }
@@ -1102,16 +1096,48 @@ fun ChatsListScreen(
tint = tint =
Color.White Color.White
) )
if (topLevelRequestsCount > 0) { // 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( Box(
modifier = modifier = Modifier
Modifier .offset(x = 6.dp, y = (-6).dp)
.align(Alignment.TopEnd) .defaultMinSize(minWidth = 20.dp, minHeight = 20.dp)
.offset(x = 4.dp, y = (-4).dp) .clip(badgeShape)
.size(10.dp) .background(
.clip(CircleShape) color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4)
.background(if (isDarkTheme) Color.White else PrimaryBlue) )
) .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,11 +3340,13 @@ fun RequestsScreen(
onPin = { onTogglePin(request.opponentKey) } onPin = { onTogglePin(request.opponentKey) }
) )
Divider( if (request != requests.last()) {
modifier = Modifier.padding(start = 84.dp), Divider(
color = dividerColor, modifier = Modifier.padding(start = 84.dp),
thickness = 0.5.dp color = dividerColor,
) thickness = 0.5.dp
)
}
} }
} }
} }

View File

@@ -42,34 +42,63 @@ fun BoxScope.BlurredAvatarBackground(
overlayColors: List<Color>? = null, overlayColors: List<Color>? = null,
isDarkTheme: Boolean = true isDarkTheme: Boolean = true
) { ) {
// В светлой теме: если дефолтный фон (avatar) — синий как шапка chat list, // В светлой теме с дефолтным фоном (avatar, без overlay) — синий как шапка chat list
// если выбран кастомный цвет в Appearance — используем его if (!isDarkTheme && (overlayColors == null || overlayColors.isEmpty())) {
if (!isDarkTheme) { Box(modifier = Modifier.matchParentSize().background(Color(0xFF0D8CF4)))
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)
return return
} }
// Если выбран цвет в Appearance — просто сплошной цвет/градиент, без blur // Если выбран цвет в Appearance — рисуем блюр аватарки + полупрозрачный overlay поверх
// (одинаково для светлой и тёмной темы, чтобы цвет совпадал с превью в Appearance)
if (overlayColors != null && overlayColors.isNotEmpty()) { 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 {
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
)
}
}
// 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 { } else {
Modifier.matchParentSize().background( Modifier.matchParentSize().background(
Brush.linearGradient(colors = overlayColors) Brush.linearGradient(colors = overlayColors.map { it.copy(alpha = overlayAlpha) })
) )
} }
Box(modifier = bgModifier) Box(modifier = overlayMod)
return return
} }

View File

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

View File

@@ -252,7 +252,14 @@ private fun ProfileBlurPreview(
// ═══════════════════════════════════════════════════ // ═══════════════════════════════════════════════════
// LAYER 2: Color/gradient overlay (или fallback) // 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) { val overlayMod = if (overlayColors.size == 1) {
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@@ -475,6 +482,24 @@ private fun ColorCircleItem(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
when { 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" -> { option.id == "avatar" -> {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -512,8 +537,8 @@ private fun ColorCircleItem(
} }
} }
// Галочка с затемнённым фоном (не для avatar — там уже иконка) // Галочка с затемнённым фоном (не для avatar/none — там уже иконка)
if (isSelected && option.id != "avatar") { if (isSelected && option.id != "avatar" && option.id != "none") {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()

View File

@@ -577,19 +577,21 @@ fun OtherProfileScreen(
) )
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// ✉️ WRITE MESSAGE BUTTON // ✉️ WRITE MESSAGE + 📞 CALL BUTTONS
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Box( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
// Write Message
Button( Button(
onClick = { onWriteMessage(user) }, onClick = { onWriteMessage(user) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .weight(1f)
.height(48.dp), .height(48.dp),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
@@ -609,12 +611,43 @@ fun OtherProfileScreen(
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = "Write Message", text = "Message",
fontSize = 16.sp, fontSize = 15.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = Color.White 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)) Spacer(modifier = Modifier.height(12.dp))
@@ -662,7 +695,8 @@ fun OtherProfileScreen(
state = pagerState, state = pagerState,
modifier = Modifier.fillMaxWidth().heightIn(min = sharedPagerMinHeight), modifier = Modifier.fillMaxWidth().heightIn(min = sharedPagerMinHeight),
beyondBoundsPageCount = 0, beyondBoundsPageCount = 0,
verticalAlignment = Alignment.Top verticalAlignment = Alignment.Top,
userScrollEnabled = false
) { page -> ) { page ->
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.TopStart) { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.TopStart) {
OtherProfileSharedTabContent( OtherProfileSharedTabContent(

View File

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