feat: add animated badge for top-level requests count in ChatsListScreen
This commit is contained in:
@@ -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,
|
||||
@@ -459,7 +464,17 @@ private fun CreateAccountModal(
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFFFFFFF)
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
BlurredAvatarBackground(
|
||||
publicKey = accountPublicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
fallbackColor = PrimaryBlueDark,
|
||||
blurRadius = 40f,
|
||||
alpha = 0.6f,
|
||||
overlayColors = emptyList(),
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
if (backgroundBlurColorId == "avatar") {
|
||||
// Avatar blur
|
||||
BlurredAvatarBackground(
|
||||
publicKey = accountPublicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
fallbackColor = if (isDarkTheme) Color(0xFF2A2A2A) else PrimaryBlue,
|
||||
blurRadius = 40f,
|
||||
alpha = 0.6f,
|
||||
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) {
|
||||
// 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
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = 4.dp, y = (-4).dp)
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (isDarkTheme) Color.White else PrimaryBlue)
|
||||
)
|
||||
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,11 +3340,13 @@ fun RequestsScreen(
|
||||
onPin = { onTogglePin(request.opponentKey) }
|
||||
)
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(start = 84.dp),
|
||||
color = dividerColor,
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
if (request != requests.last()) {
|
||||
Divider(
|
||||
modifier = Modifier.padding(start = 84.dp),
|
||||
color = dividerColor,
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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 {
|
||||
Modifier.matchParentSize().background(
|
||||
Brush.linearGradient(colors = overlayColors)
|
||||
Brush.linearGradient(colors = overlayColors.map { it.copy(alpha = overlayAlpha) })
|
||||
)
|
||||
}
|
||||
Box(modifier = bgModifier)
|
||||
Box(modifier = overlayMod)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -568,6 +568,14 @@ fun ProfileScreen(
|
||||
hasTriggeredSnapBackHaptic = true
|
||||
}
|
||||
}
|
||||
|
||||
// Коллапс при удалении аватара — сразу сворачиваем
|
||||
LaunchedEffect(hasAvatar) {
|
||||
if (!hasAvatar) {
|
||||
isPulledDown = false
|
||||
overscrollOffset = 0f
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// NESTED SCROLL — overscroll (pull-down аватарка) + header snap
|
||||
@@ -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,15 +1134,24 @@ private fun CollapsingProfileHeader(
|
||||
// и естественно перекрывает его. Без мерцания.
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
Box(modifier = Modifier.matchParentSize()) {
|
||||
BlurredAvatarBackground(
|
||||
publicKey = publicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
fallbackColor = avatarColors.backgroundColor,
|
||||
blurRadius = 20f,
|
||||
alpha = 0.9f,
|
||||
overlayColors = BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId),
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
if (backgroundBlurColorId == "none") {
|
||||
// None — стандартный цвет шапки без blur
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4))
|
||||
)
|
||||
} else {
|
||||
BlurredAvatarBackground(
|
||||
publicKey = publicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
fallbackColor = avatarColors.backgroundColor,
|
||||
blurRadius = 20f,
|
||||
alpha = 0.9f,
|
||||
overlayColors = BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId),
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user