From a46968cfff5861dafef1a2d10d84d30012119c3f Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 14 Feb 2026 00:31:05 +0500 Subject: [PATCH] feat: add animated badge for top-level requests count in ChatsListScreen --- .../messenger/ui/auth/SelectAccountScreen.kt | 23 +++- .../messenger/ui/chats/ChatsListScreen.kt | 100 +++++++++++------- .../ui/components/BlurredAvatarBackground.kt | 69 ++++++++---- .../messenger/ui/settings/AppearanceColors.kt | 15 ++- .../messenger/ui/settings/AppearanceScreen.kt | 31 +++++- .../ui/settings/OtherProfileScreen.kt | 48 +++++++-- .../messenger/ui/settings/ProfileScreen.kt | 39 +++++-- 7 files changed, 242 insertions(+), 83 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt index e1cd8b8..3ef8b14 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt @@ -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) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index e38db2e..4c88d7d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -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 + ) + } } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt b/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt index bb0afa9..5e84377 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt @@ -42,34 +42,63 @@ fun BoxScope.BlurredAvatarBackground( overlayColors: List? = 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(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 } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceColors.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceColors.kt index fa624bc..c5eba6e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceColors.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceColors.kt @@ -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 = solidColors + gradients - /** Все варианты включая "Avatar" (default) */ - val allWithDefault: List = listOf(avatarDefault) + all + /** Все варианты включая "Avatar" и "None" */ + val allWithDefault: List = 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? { val option = findById(id) diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt index 4ab277e..fcd828d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt @@ -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() diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index 1b8a348..4a74c13 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -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( diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index d9790bf..ffe4e3e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -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 + ) + } } // ═══════════════════════════════════════════════════════════