QR экран: обои вместо градиентов, circular reveal анимация при смене обоев и темы, кнопка sun/moon переключает тему приложения, cooldown 600ms. Убрана кнопка Start New Call с экрана звонков.

This commit is contained in:
2026-04-08 20:06:56 +05:00
parent 325073fc09
commit ae78e4a162
3 changed files with 255 additions and 96 deletions

View File

@@ -1863,7 +1863,8 @@ fun MainScreen(
onScanQr = { onScanQr = {
navStack = navStack.filterNot { it is Screen.MyQr } navStack = navStack.filterNot { it is Screen.MyQr }
pushScreen(Screen.QrScanner) pushScreen(Screen.QrScanner)
} },
onToggleTheme = onToggleTheme
) )
} }

View File

@@ -109,11 +109,6 @@ fun CallsHistoryScreen(
contentPadding = PaddingValues(bottom = 16.dp) contentPadding = PaddingValues(bottom = 16.dp)
) { ) {
item(key = "start_new_call") { item(key = "start_new_call") {
StartNewCallRow(
isDarkTheme = isDarkTheme,
onClick = onStartNewCall
)
Divider(color = dividerColor, thickness = 0.5.dp)
Text( Text(
text = "You can add up to 200 participants to a call.", text = "You can add up to 200 participants to a call.",
color = secondaryTextColor, color = secondaryTextColor,

View File

@@ -1,6 +1,9 @@
package com.rosetta.messenger.ui.qr package com.rosetta.messenger.ui.qr
import android.content.Intent import android.content.Intent
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -10,55 +13,69 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment 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.Brush import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
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
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.view.drawToBitmap
import com.rosetta.messenger.R
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.settings.ThemeWallpapers
import com.rosetta.messenger.utils.QrCodeGenerator import com.rosetta.messenger.utils.QrCodeGenerator
import compose.icons.TablerIcons import compose.icons.TablerIcons
import compose.icons.tablericons.* import compose.icons.tablericons.*
import androidx.compose.ui.layout.onSizeChanged
import kotlinx.coroutines.launch
import kotlin.math.hypot
// Theme presets — gradient background + QR foreground color // QR theme = wallpaper + QR color
private data class QrTheme( private data class QrTheme(
val gradientColors: List<Color>, val wallpaperId: String,
val qrColor: Int, val qrColor: Int,
val name: String val tintColor: Color,
val isDark: Boolean
) )
private val qrThemes = listOf( private val qrThemes = listOf(
QrTheme( QrTheme("dark_01", 0xFF5AA5FF.toInt(), Color(0xFF5AA5FF), true),
listOf(Color(0xFF667EEA), Color(0xFF764BA2)), QrTheme("dark_02", 0xFF9575CD.toInt(), Color(0xFF9575CD), true),
0xFF4A3F9F.toInt(), QrTheme("dark_03", 0xFF4DB6AC.toInt(), Color(0xFF4DB6AC), true),
"Purple" QrTheme("light_01", 0xFF228BE6.toInt(), Color(0xFF228BE6), false),
), QrTheme("light_02", 0xFF43A047.toInt(), Color(0xFF43A047), false),
QrTheme( QrTheme("light_03", 0xFFE64980.toInt(), Color(0xFFE64980), false),
listOf(Color(0xFF43E97B), Color(0xFF38F9D7)),
0xFF2D8F5E.toInt(),
"Green"
),
QrTheme(
listOf(Color(0xFFF78CA0), Color(0xFFF9748F), Color(0xFFFD868C)),
0xFFBF3A5A.toInt(),
"Pink"
),
QrTheme(
listOf(Color(0xFF4FACFE), Color(0xFF00F2FE)),
0xFF2171B5.toInt(),
"Blue"
),
QrTheme(
listOf(Color(0xFFFFA751), Color(0xFFFFE259)),
0xFFC67B1C.toInt(),
"Orange"
),
) )
private fun maxRevealRadius(center: Offset, bounds: IntSize): Float {
if (bounds.width <= 0 || bounds.height <= 0) return 0f
val w = bounds.width.toFloat()
val h = bounds.height.toFloat()
return maxOf(
hypot(center.x, center.y),
hypot(w - center.x, center.y),
hypot(center.x, h - center.y),
hypot(w - center.x, h - center.y)
)
}
@Composable @Composable
fun MyQrCodeScreen( fun MyQrCodeScreen(
isDarkTheme: Boolean, isDarkTheme: Boolean,
@@ -67,12 +84,31 @@ fun MyQrCodeScreen(
username: String, username: String,
avatarRepository: AvatarRepository? = null, avatarRepository: AvatarRepository? = null,
onBack: () -> Unit, onBack: () -> Unit,
onScanQr: () -> Unit = {} onScanQr: () -> Unit = {},
onToggleTheme: () -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
var selectedThemeIndex by remember { mutableIntStateOf(0) } val view = LocalView.current
val scope = rememberCoroutineScope()
var selectedThemeIndex by remember { mutableIntStateOf(if (isDarkTheme) 0 else 3) }
// Auto-switch to matching theme group when app theme changes
LaunchedEffect(isDarkTheme) {
val currentTheme = qrThemes.getOrNull(selectedThemeIndex)
if (currentTheme != null && currentTheme.isDark != isDarkTheme) {
// Map to same position in the other group
val posInGroup = if (currentTheme.isDark) selectedThemeIndex else selectedThemeIndex - 3
selectedThemeIndex = if (isDarkTheme) posInGroup.coerceIn(0, 2) else (posInGroup + 3).coerceIn(3, 5)
}
}
val theme = qrThemes[selectedThemeIndex] val theme = qrThemes[selectedThemeIndex]
val wallpaperResId = remember(theme.wallpaperId) {
ThemeWallpapers.drawableResOrNull(theme.wallpaperId)
}
val qrBitmap = remember(publicKey, selectedThemeIndex) { val qrBitmap = remember(publicKey, selectedThemeIndex) {
QrCodeGenerator.generateQrBitmap( QrCodeGenerator.generateQrBitmap(
content = QrCodeGenerator.profilePayload(publicKey), content = QrCodeGenerator.profilePayload(publicKey),
@@ -88,27 +124,90 @@ fun MyQrCodeScreen(
else QrCodeGenerator.profileShareUrlByKey(publicKey) else QrCodeGenerator.profileShareUrlByKey(publicKey)
} }
// Circular reveal state
val revealRadius = remember { Animatable(0f) }
var revealActive by remember { mutableStateOf(false) }
var revealSnapshot by remember { mutableStateOf<ImageBitmap?>(null) }
var revealCenter by remember { mutableStateOf(Offset.Zero) }
var rootSize by remember { mutableStateOf(IntSize.Zero) }
var lastRevealTime by remember { mutableLongStateOf(0L) }
val revealCooldownMs = 600L
fun startReveal(newIndex: Int, center: Offset) {
val now = System.currentTimeMillis()
if (revealActive || newIndex == selectedThemeIndex || now - lastRevealTime < revealCooldownMs) return
lastRevealTime = now
if (rootSize.width <= 0 || rootSize.height <= 0) {
selectedThemeIndex = newIndex
return
}
val snapshot = runCatching { view.drawToBitmap() }.getOrNull()
if (snapshot == null) {
selectedThemeIndex = newIndex
return
}
val maxR = maxRevealRadius(center, rootSize)
if (maxR <= 0f) {
selectedThemeIndex = newIndex
return
}
revealActive = true
revealCenter = center
revealSnapshot = snapshot.asImageBitmap()
scope.launch {
try {
revealRadius.snapTo(0f)
selectedThemeIndex = newIndex
kotlinx.coroutines.delay(16) // wait one frame for recomposition
revealRadius.animateTo(
targetValue = maxR,
animationSpec = tween(
durationMillis = 400,
easing = CubicBezierEasing(0.45f, 0.05f, 0.55f, 0.95f)
)
)
} finally {
revealSnapshot = null
revealActive = false
}
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Brush.verticalGradient(theme.gradientColors)) .onSizeChanged { rootSize = it }
) { ) {
// Wallpaper background
if (wallpaperResId != null) {
Image(
painter = painterResource(id = wallpaperResId),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Box(modifier = Modifier.fillMaxSize().background(if (theme.isDark) Color(0xFF1A1A1A) else Color(0xFFF0F0F5)))
}
// Main content
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Spacer(modifier = Modifier.statusBarsPadding().height(40.dp)) Spacer(modifier = Modifier.statusBarsPadding().height(40.dp))
// QR Card with avatar overlapping // QR Card with overlapping avatar
Box( Box(
modifier = Modifier.padding(horizontal = 32.dp), modifier = Modifier.padding(horizontal = 32.dp),
contentAlignment = Alignment.TopCenter contentAlignment = Alignment.TopCenter
) { ) {
// White card
Card( Card(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(top = 40.dp),
.fillMaxWidth()
.padding(top = 40.dp),
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = Color.White), colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
@@ -119,7 +218,6 @@ fun MyQrCodeScreen(
.padding(top = 52.dp, bottom = 24.dp, start = 24.dp, end = 24.dp), .padding(top = 52.dp, bottom = 24.dp, start = 24.dp, end = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// QR code
if (qrBitmap != null) { if (qrBitmap != null) {
Image( Image(
bitmap = qrBitmap.asImageBitmap(), bitmap = qrBitmap.asImageBitmap(),
@@ -130,7 +228,6 @@ fun MyQrCodeScreen(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Username
Text( Text(
text = if (username.isNotBlank()) "@${username.uppercase()}" text = if (username.isNotBlank()) "@${username.uppercase()}"
else displayName.uppercase(), else displayName.uppercase(),
@@ -142,7 +239,7 @@ fun MyQrCodeScreen(
} }
} }
// Avatar overlapping the card top // Avatar overlapping
Box( Box(
modifier = Modifier modifier = Modifier
.size(80.dp) .size(80.dp)
@@ -163,12 +260,11 @@ fun MyQrCodeScreen(
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
// Bottom sheet area // Bottom sheet
Surface( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
color = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White, color = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White,
tonalElevation = 0.dp,
shadowElevation = 16.dp shadowElevation = 16.dp
) { ) {
Column( Column(
@@ -178,71 +274,120 @@ fun MyQrCodeScreen(
.padding(top = 16.dp, bottom = 16.dp), .padding(top = 16.dp, bottom = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// Handle bar // Handle
Box( Box(
modifier = Modifier modifier = Modifier
.width(36.dp) .width(36.dp).height(4.dp)
.height(4.dp)
.clip(RoundedCornerShape(2.dp)) .clip(RoundedCornerShape(2.dp))
.background(Color.Gray.copy(alpha = 0.3f)) .background(Color.Gray.copy(alpha = 0.3f))
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Header: Close + title // Header: X + "QR Code" + theme toggle
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(TablerIcons.X, contentDescription = "Close",
tint = if (isDarkTheme) Color.White else Color.Black)
}
Spacer(modifier = Modifier.weight(1f))
Text("QR Code", fontSize = 17.sp, fontWeight = FontWeight.SemiBold,
color = if (isDarkTheme) Color.White else Color.Black)
Spacer(modifier = Modifier.weight(1f))
var themeButtonPos by remember { mutableStateOf(Offset.Zero) }
IconButton(
onClick = {
// Snapshot → toggle theme → circular reveal
val now = System.currentTimeMillis()
if (!revealActive && rootSize.width > 0 && now - lastRevealTime >= revealCooldownMs) {
lastRevealTime = now
val snapshot = runCatching { view.drawToBitmap() }.getOrNull()
if (snapshot != null) {
val maxR = maxRevealRadius(themeButtonPos, rootSize)
revealActive = true
revealCenter = themeButtonPos
revealSnapshot = snapshot.asImageBitmap()
// Switch to matching wallpaper in new theme
val posInGroup = if (isDarkTheme) selectedThemeIndex else selectedThemeIndex - 3
val newIndex = if (isDarkTheme) (posInGroup + 3).coerceIn(3, 5) else posInGroup.coerceIn(0, 2)
selectedThemeIndex = newIndex
onToggleTheme()
scope.launch {
try {
revealRadius.snapTo(0f)
kotlinx.coroutines.delay(16)
revealRadius.animateTo(
targetValue = maxR,
animationSpec = tween(400, easing = CubicBezierEasing(0.45f, 0.05f, 0.55f, 0.95f))
)
} finally {
revealSnapshot = null
revealActive = false
}
}
} else {
onToggleTheme()
}
} else {
onToggleTheme()
}
},
modifier = Modifier.onGloballyPositioned { coords ->
val pos = coords.positionInRoot()
val sz = coords.size
themeButtonPos = Offset(pos.x + sz.width / 2f, pos.y + sz.height / 2f)
}
) {
Icon( Icon(
TablerIcons.X, imageVector = if (isDarkTheme) TablerIcons.Sun else TablerIcons.MoonStars,
contentDescription = "Close", contentDescription = "Toggle theme",
tint = if (isDarkTheme) Color.White else Color.Black tint = if (isDarkTheme) Color.White else Color.Black
) )
} }
Spacer(modifier = Modifier.weight(1f))
Text(
"QR Code",
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold,
color = if (isDarkTheme) Color.White else Color.Black
)
Spacer(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.width(48.dp))
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Theme selector // Wallpaper selector — show current theme's wallpapers
val currentThemes = qrThemes.filter { it.isDark == isDarkTheme }
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp) horizontalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
qrThemes.forEachIndexed { index, t -> currentThemes.forEach { t ->
val index = qrThemes.indexOf(t)
val isSelected = index == selectedThemeIndex val isSelected = index == selectedThemeIndex
val wpRes = ThemeWallpapers.drawableResOrNull(t.wallpaperId)
var itemPosition by remember { mutableStateOf(Offset.Zero) }
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f).aspectRatio(0.85f)
.aspectRatio(0.85f)
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.border( .border(
width = if (isSelected) 2.dp else 0.dp, width = if (isSelected) 2.dp else 0.dp,
color = if (isSelected) Color(0xFF3390EC) else Color.Transparent, color = if (isSelected) Color(0xFF3390EC) else Color.Transparent,
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp)
) )
.background(Brush.verticalGradient(t.gradientColors)) .onGloballyPositioned { coords ->
.clickable { selectedThemeIndex = index }, val pos = coords.positionInRoot()
val sz = coords.size
itemPosition = Offset(pos.x + sz.width / 2f, pos.y + sz.height / 2f)
}
.clickable { if (index != selectedThemeIndex) startReveal(index, itemPosition) },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( if (wpRes != null) {
TablerIcons.Scan, Image(painter = painterResource(id = wpRes), contentDescription = null,
contentDescription = t.name, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
tint = Color.White.copy(alpha = 0.8f), }
modifier = Modifier.size(24.dp) Icon(TablerIcons.Scan, contentDescription = null,
) tint = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.35f),
modifier = Modifier.size(22.dp))
} }
} }
} }
@@ -258,10 +403,7 @@ fun MyQrCodeScreen(
} }
context.startActivity(Intent.createChooser(intent, "Share Profile")) context.startActivity(Intent.createChooser(intent, "Share Profile"))
}, },
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).height(50.dp),
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(50.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF3390EC)), colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF3390EC)),
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp)
) { ) {
@@ -270,24 +412,45 @@ fun MyQrCodeScreen(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Scan QR button // Scan QR
TextButton(onClick = onScanQr) { TextButton(onClick = onScanQr) {
Icon( Icon(TablerIcons.Scan, contentDescription = null,
TablerIcons.Scan, tint = Color(0xFF3390EC), modifier = Modifier.size(20.dp))
contentDescription = null,
tint = Color(0xFF3390EC),
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text("Scan QR Code", color = Color(0xFF3390EC),
"Scan QR Code", fontSize = 15.sp, fontWeight = FontWeight.Medium)
color = Color(0xFF3390EC),
fontSize = 15.sp,
fontWeight = FontWeight.Medium
)
} }
} }
} }
} }
// Circular reveal overlay
if (revealActive) {
val snapshot = revealSnapshot
if (snapshot != null) {
Canvas(
modifier = Modifier
.fillMaxSize()
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
) {
val dstSize = IntSize(size.width.toInt(), size.height.toInt())
// Draw old screenshot
drawImage(
image = snapshot,
srcOffset = IntOffset.Zero,
srcSize = IntSize(snapshot.width, snapshot.height),
dstOffset = IntOffset.Zero,
dstSize = dstSize
)
// Cut expanding circle to reveal new theme underneath
drawCircle(
color = Color.Transparent,
radius = revealRadius.value,
center = revealCenter,
blendMode = BlendMode.Clear
)
}
}
}
} }
} }