Промежуточный результат для 1.0.4 версии
This commit is contained in:
@@ -1,10 +1,17 @@
|
||||
package com.rosetta.messenger.ui.settings
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.PixelCopy
|
||||
import android.view.View
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -24,23 +31,34 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
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.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.utils.AvatarFileManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import compose.icons.tablericons.Sun
|
||||
import compose.icons.tablericons.Moon
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Экран кастомизации внешнего вида.
|
||||
@@ -74,9 +92,85 @@ fun AppearanceScreen(
|
||||
|
||||
var selectedId by remember { mutableStateOf(currentBlurColorId) }
|
||||
|
||||
// ── Circular reveal state ──
|
||||
val scope = rememberCoroutineScope()
|
||||
var screenshotBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
val revealProgress = remember { Animatable(1f) } // 1 = fully revealed (no overlay)
|
||||
var revealCenter by remember { mutableStateOf(Offset.Zero) }
|
||||
var screenSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
|
||||
// Helper: capture screenshot of the current view, then animate circular reveal
|
||||
fun triggerCircularReveal(center: Offset, applyChange: () -> Unit) {
|
||||
// Don't start a new animation while one is running
|
||||
if (screenshotBitmap != null) return
|
||||
|
||||
val rootView = view.rootView
|
||||
val width = rootView.width
|
||||
val height = rootView.height
|
||||
if (width <= 0 || height <= 0) {
|
||||
applyChange()
|
||||
return
|
||||
}
|
||||
|
||||
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
|
||||
fun onScreenshotReady() {
|
||||
screenshotBitmap = bmp
|
||||
revealCenter = center
|
||||
scope.launch {
|
||||
revealProgress.snapTo(0f)
|
||||
applyChange()
|
||||
revealProgress.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(durationMillis = 380)
|
||||
)
|
||||
screenshotBitmap = null
|
||||
}
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val window = (view.context as? android.app.Activity)?.window
|
||||
if (window != null) {
|
||||
PixelCopy.request(
|
||||
window,
|
||||
bmp,
|
||||
{ result ->
|
||||
if (result == PixelCopy.SUCCESS) {
|
||||
onScreenshotReady()
|
||||
} else {
|
||||
applyChange()
|
||||
}
|
||||
},
|
||||
Handler(Looper.getMainLooper())
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Fallback for older APIs
|
||||
@Suppress("DEPRECATION")
|
||||
rootView.isDrawingCacheEnabled = true
|
||||
@Suppress("DEPRECATION")
|
||||
rootView.buildDrawingCache()
|
||||
@Suppress("DEPRECATION")
|
||||
val cache = rootView.drawingCache
|
||||
if (cache != null) {
|
||||
val canvas = android.graphics.Canvas(bmp)
|
||||
canvas.drawBitmap(cache, 0f, 0f, null)
|
||||
@Suppress("DEPRECATION")
|
||||
rootView.destroyDrawingCache()
|
||||
onScreenshotReady()
|
||||
} else {
|
||||
applyChange()
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler { onBack() }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.onGloballyPositioned { screenSize = it.size }
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -119,7 +213,18 @@ fun AppearanceScreen(
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onToggleTheme() }) {
|
||||
var themeButtonCenter by remember { mutableStateOf(Offset.Zero) }
|
||||
IconButton(
|
||||
onClick = {
|
||||
triggerCircularReveal(themeButtonCenter) {
|
||||
onToggleTheme()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.onGloballyPositioned { coords ->
|
||||
val bounds = coords.boundsInRoot()
|
||||
themeButtonCenter = bounds.center
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isDarkTheme) TablerIcons.Sun else TablerIcons.Moon,
|
||||
contentDescription = "Toggle theme",
|
||||
@@ -149,9 +254,13 @@ fun AppearanceScreen(
|
||||
ColorSelectionGrid(
|
||||
selectedId = selectedId,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onSelect = { id ->
|
||||
selectedId = id
|
||||
onBlurColorChange(id)
|
||||
onSelect = { id, centerInRoot ->
|
||||
if (id != selectedId) {
|
||||
triggerCircularReveal(centerInRoot) {
|
||||
selectedId = id
|
||||
onBlurColorChange(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -168,6 +277,37 @@ fun AppearanceScreen(
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Circular reveal overlay ──
|
||||
// Screenshot of old state is drawn with a circular hole that grows,
|
||||
// revealing the new state underneath.
|
||||
val bmp = screenshotBitmap
|
||||
if (bmp != null) {
|
||||
val progress = revealProgress.value
|
||||
val maxRadius = hypot(
|
||||
max(revealCenter.x, screenSize.width - revealCenter.x),
|
||||
max(revealCenter.y, screenSize.height - revealCenter.y)
|
||||
)
|
||||
val currentRadius = maxRadius * progress
|
||||
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen }
|
||||
) {
|
||||
// Draw the old screenshot
|
||||
drawImage(
|
||||
image = bmp.asImageBitmap()
|
||||
)
|
||||
// Cut a circular hole — reveals new content underneath
|
||||
drawCircle(
|
||||
color = Color.Black,
|
||||
radius = currentRadius,
|
||||
center = revealCenter,
|
||||
blendMode = BlendMode.DstOut
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,7 +553,7 @@ private fun ProfileBlurPreview(
|
||||
private fun ColorSelectionGrid(
|
||||
selectedId: String,
|
||||
isDarkTheme: Boolean,
|
||||
onSelect: (String) -> Unit
|
||||
onSelect: (String, Offset) -> Unit
|
||||
) {
|
||||
val allOptions = BackgroundBlurPresets.allWithDefault
|
||||
val horizontalPadding = 12.dp
|
||||
@@ -460,7 +600,7 @@ private fun ColorSelectionGrid(
|
||||
isSelected = option.id == selectedId,
|
||||
isDarkTheme = isDarkTheme,
|
||||
circleSize = circleSize,
|
||||
onClick = { onSelect(option.id) }
|
||||
onSelectWithPosition = { centerInRoot -> onSelect(option.id, centerInRoot) }
|
||||
)
|
||||
}
|
||||
repeat(columns - rowItems.size) {
|
||||
@@ -478,7 +618,7 @@ private fun ColorCircleItem(
|
||||
isSelected: Boolean,
|
||||
isDarkTheme: Boolean,
|
||||
circleSize: Dp,
|
||||
onClick: () -> Unit
|
||||
onSelectWithPosition: (Offset) -> Unit
|
||||
) {
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isSelected) 1.08f else 1.0f,
|
||||
@@ -496,17 +636,26 @@ private fun ColorCircleItem(
|
||||
label = "border"
|
||||
)
|
||||
|
||||
// Track center position in root coordinates for circular reveal
|
||||
var boundsInRoot by remember { mutableStateOf(Rect.Zero) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(circleSize)
|
||||
.scale(scale)
|
||||
.onGloballyPositioned { coords ->
|
||||
boundsInRoot = coords.boundsInRoot()
|
||||
}
|
||||
.clip(CircleShape)
|
||||
.border(
|
||||
width = if (isSelected) 2.5.dp else 0.5.dp,
|
||||
color = if (isSelected) borderColor else if (isDarkTheme) Color.White.copy(alpha = 0.12f) else Color.Black.copy(alpha = 0.12f),
|
||||
shape = CircleShape
|
||||
)
|
||||
.clickable(onClick = onClick),
|
||||
.clickable {
|
||||
val center = boundsInRoot.center
|
||||
onSelectWithPosition(center)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when {
|
||||
|
||||
@@ -201,6 +201,9 @@ fun OtherProfileScreen(
|
||||
val isSafetyProfile = remember(user.publicKey) {
|
||||
user.publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY
|
||||
}
|
||||
val isSystemAccount = remember(user.publicKey) {
|
||||
MessageRepository.isSystemAccount(user.publicKey)
|
||||
}
|
||||
val context = LocalContext.current
|
||||
val view = LocalView.current
|
||||
val window = remember { (view.context as? Activity)?.window }
|
||||
@@ -590,7 +593,7 @@ fun OtherProfileScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (!isSafetyProfile) {
|
||||
if (!isSafetyProfile && !isSystemAccount) {
|
||||
// Call
|
||||
Button(
|
||||
onClick = { /* TODO: call action */ },
|
||||
@@ -791,6 +794,7 @@ fun OtherProfileScreen(
|
||||
showAvatarMenu = showAvatarMenu,
|
||||
onAvatarMenuChange = { showAvatarMenu = it },
|
||||
isBlocked = isBlocked,
|
||||
isSystemAccount = isSystemAccount,
|
||||
onBlockToggle = {
|
||||
coroutineScope.launch {
|
||||
if (isBlocked) {
|
||||
@@ -1727,7 +1731,7 @@ private fun OtherProfileEmptyState(
|
||||
@Composable
|
||||
private fun CollapsingOtherProfileHeader(
|
||||
name: String,
|
||||
@Suppress("UNUSED_PARAMETER") username: String,
|
||||
username: String,
|
||||
publicKey: String,
|
||||
verified: Int,
|
||||
isOnline: Boolean,
|
||||
@@ -1740,6 +1744,7 @@ private fun CollapsingOtherProfileHeader(
|
||||
showAvatarMenu: Boolean,
|
||||
onAvatarMenuChange: (Boolean) -> Unit,
|
||||
isBlocked: Boolean,
|
||||
isSystemAccount: Boolean = false,
|
||||
onBlockToggle: () -> Unit,
|
||||
avatarRepository: AvatarRepository? = null,
|
||||
onClearChat: () -> Unit,
|
||||
@@ -1769,6 +1774,10 @@ private fun CollapsingOtherProfileHeader(
|
||||
|
||||
val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress)
|
||||
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress)
|
||||
val rosettaBadgeBlue = Color(0xFF1DA1F2)
|
||||
val isRosettaOfficial =
|
||||
name.equals("Rosetta", ignoreCase = true) ||
|
||||
username.equals("rosetta", ignoreCase = true)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой
|
||||
@@ -1878,6 +1887,13 @@ private fun CollapsingOtherProfileHeader(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else if (publicKey == MessageRepository.SYSTEM_UPDATES_PUBLIC_KEY) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.updates_account),
|
||||
contentDescription = "Rosetta Updates avatar",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else if (hasAvatar && avatarRepository != null) {
|
||||
OtherProfileFullSizeAvatar(
|
||||
publicKey = publicKey,
|
||||
@@ -1975,6 +1991,7 @@ private fun CollapsingOtherProfileHeader(
|
||||
onDismiss = { onAvatarMenuChange(false) },
|
||||
isDarkTheme = isDarkTheme,
|
||||
isBlocked = isBlocked,
|
||||
isSystemAccount = isSystemAccount,
|
||||
onBlockClick = {
|
||||
onAvatarMenuChange(false)
|
||||
onBlockToggle()
|
||||
@@ -2010,9 +2027,14 @@ private fun CollapsingOtherProfileHeader(
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
if (verified > 0) {
|
||||
if (verified > 0 || isRosettaOfficial) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
VerifiedBadge(verified = verified, size = (nameFontSize.value * 0.8f).toInt(), isDarkTheme = isDarkTheme)
|
||||
VerifiedBadge(
|
||||
verified = if (verified > 0) verified else 1,
|
||||
size = (nameFontSize.value * 0.8f).toInt(),
|
||||
isDarkTheme = isDarkTheme,
|
||||
badgeTint = if (isRosettaOfficial) rosettaBadgeBlue else null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ import com.rosetta.messenger.biometric.BiometricAvailability
|
||||
import com.rosetta.messenger.biometric.BiometricPreferences
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
|
||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlueDark
|
||||
@@ -1067,7 +1068,7 @@ fun ProfileScreen(
|
||||
@Composable
|
||||
private fun CollapsingProfileHeader(
|
||||
name: String,
|
||||
@Suppress("UNUSED_PARAMETER") username: String,
|
||||
username: String,
|
||||
publicKey: String,
|
||||
avatarColors: AvatarColors,
|
||||
collapseProgress: Float,
|
||||
@@ -1128,11 +1129,17 @@ private fun CollapsingProfileHeader(
|
||||
// Font sizes
|
||||
val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress)
|
||||
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress)
|
||||
val rosettaBadgeBlue = Color(0xFF1DA1F2)
|
||||
val isRosettaOfficial =
|
||||
name.equals("Rosetta", ignoreCase = true) ||
|
||||
username.equals("rosetta", ignoreCase = true)
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) {
|
||||
// Expansion fraction — computed early so gradient can fade during expansion
|
||||
val expandFraction = expansionProgress.coerceIn(0f, 1f)
|
||||
val headerBaseColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4)
|
||||
// Neutral screen bg — used under blur/overlay so blue doesn't bleed through
|
||||
val screenBgColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🎨 BLURRED AVATAR BACKGROUND — ВСЕГДА видим
|
||||
@@ -1140,15 +1147,16 @@ private fun CollapsingProfileHeader(
|
||||
// и естественно перекрывает его. Без мерцания.
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
Box(modifier = Modifier.matchParentSize()) {
|
||||
Box(modifier = Modifier.matchParentSize().background(headerBaseColor))
|
||||
if (backgroundBlurColorId == "none") {
|
||||
// None — стандартный цвет шапки без blur
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.matchParentSize()
|
||||
.background(headerBaseColor)
|
||||
)
|
||||
} else {
|
||||
// Neutral base so transparent blur layers don't pick up blue tint
|
||||
Box(modifier = Modifier.matchParentSize().background(screenBgColor))
|
||||
BlurredAvatarBackground(
|
||||
publicKey = publicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
@@ -1375,16 +1383,30 @@ private fun CollapsingProfileHeader(
|
||||
Modifier.align(Alignment.TopCenter).offset(y = textY),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
fontSize = nameFontSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 220.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
fontSize = nameFontSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 220.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
if (isRosettaOfficial) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
VerifiedBadge(
|
||||
verified = 2,
|
||||
size = (nameFontSize.value * 0.8f).toInt(),
|
||||
isDarkTheme = isDarkTheme,
|
||||
badgeTint = rosettaBadgeBlue
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.rosetta.messenger.ui.settings
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@@ -20,13 +23,42 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.view.drawToBitmap
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||
import kotlin.math.hypot
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private fun maxRevealRadius(center: Offset, bounds: IntSize): Float {
|
||||
if (bounds.width <= 0 || bounds.height <= 0) return 0f
|
||||
val width = bounds.width.toFloat()
|
||||
val height = bounds.height.toFloat()
|
||||
return maxOf(
|
||||
hypot(center.x, center.y),
|
||||
hypot(width - center.x, center.y),
|
||||
hypot(center.x, height - center.y),
|
||||
hypot(width - center.x, height - center.y)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThemeScreen(
|
||||
@@ -49,123 +81,251 @@ fun ThemeScreen(
|
||||
val surfaceColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
val scope = rememberCoroutineScope()
|
||||
val systemIsDark = isSystemInDarkTheme()
|
||||
|
||||
// Theme mode: "light", "dark", "auto"
|
||||
var themeMode by remember { mutableStateOf(currentThemeMode) }
|
||||
val themeRevealRadius = remember { Animatable(0f) }
|
||||
var rootSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
var themeRevealActive by remember { mutableStateOf(false) }
|
||||
var themeRevealToDark by remember { mutableStateOf(false) }
|
||||
var themeRevealCenter by remember { mutableStateOf(Offset.Zero) }
|
||||
var themeRevealSnapshot by remember { mutableStateOf<ImageBitmap?>(null) }
|
||||
var lightOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
||||
var darkOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
||||
var systemOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
||||
|
||||
LaunchedEffect(currentThemeMode) {
|
||||
themeMode = currentThemeMode
|
||||
}
|
||||
|
||||
fun resolveThemeIsDark(mode: String): Boolean =
|
||||
when (mode) {
|
||||
"dark" -> true
|
||||
"light" -> false
|
||||
else -> systemIsDark
|
||||
}
|
||||
|
||||
fun startThemeReveal(targetMode: String, centerHint: Offset?) {
|
||||
if (themeRevealActive || themeMode == targetMode) return
|
||||
val targetIsDark = resolveThemeIsDark(targetMode)
|
||||
if (targetIsDark == isDarkTheme || rootSize.width <= 0 || rootSize.height <= 0) {
|
||||
themeMode = targetMode
|
||||
onThemeModeChange(targetMode)
|
||||
return
|
||||
}
|
||||
|
||||
val center =
|
||||
centerHint ?: Offset(rootSize.width * 0.85f, rootSize.height * 0.18f)
|
||||
val snapshotBitmap = runCatching { view.drawToBitmap() }.getOrNull()
|
||||
if (snapshotBitmap == null) {
|
||||
themeMode = targetMode
|
||||
onThemeModeChange(targetMode)
|
||||
return
|
||||
}
|
||||
|
||||
val maxRadius = maxRevealRadius(center, rootSize)
|
||||
if (maxRadius <= 0f) {
|
||||
themeMode = targetMode
|
||||
onThemeModeChange(targetMode)
|
||||
return
|
||||
}
|
||||
|
||||
themeRevealActive = true
|
||||
themeRevealToDark = targetIsDark
|
||||
themeRevealCenter = center
|
||||
themeRevealSnapshot = snapshotBitmap.asImageBitmap()
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
if (targetIsDark) {
|
||||
themeRevealRadius.snapTo(0f)
|
||||
} else {
|
||||
themeRevealRadius.snapTo(maxRadius)
|
||||
}
|
||||
themeMode = targetMode
|
||||
onThemeModeChange(targetMode)
|
||||
withFrameNanos { }
|
||||
themeRevealRadius.animateTo(
|
||||
targetValue = if (targetIsDark) maxRadius else 0f,
|
||||
animationSpec =
|
||||
tween(
|
||||
durationMillis = 400,
|
||||
easing = CubicBezierEasing(0.45f, 0.05f, 0.55f, 0.95f)
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
themeRevealSnapshot = null
|
||||
themeRevealActive = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle back gesture
|
||||
BackHandler { onBack() }
|
||||
|
||||
Column(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.onSizeChanged { rootSize = it }
|
||||
.background(backgroundColor)
|
||||
) {
|
||||
// Top Bar
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = backgroundColor
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding())
|
||||
.padding(horizontal = 4.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
// Top Bar
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = backgroundColor
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.ChevronLeft,
|
||||
contentDescription = "Back",
|
||||
tint = textColor
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding())
|
||||
.padding(horizontal = 4.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.ChevronLeft,
|
||||
contentDescription = "Back",
|
||||
tint = textColor
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Theme",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Theme",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
}
|
||||
|
||||
// Content
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// CHAT PREVIEW - Message bubbles like in real chat
|
||||
// ═══════════════════════════════════════════════════════
|
||||
ChatPreview(isDarkTheme = isDarkTheme)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// MODE SELECTOR - Telegram style
|
||||
// ═══════════════════════════════════════════════════════
|
||||
TelegramSectionHeader("Appearance", secondaryTextColor)
|
||||
|
||||
Column {
|
||||
TelegramThemeOption(
|
||||
icon = TablerIcons.Sun,
|
||||
title = "Light",
|
||||
isSelected = themeMode == "light",
|
||||
onClick = { startThemeReveal("light", lightOptionCenter) },
|
||||
textColor = textColor,
|
||||
secondaryTextColor = secondaryTextColor,
|
||||
showDivider = true,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onCenterInRootChanged = { lightOptionCenter = it }
|
||||
)
|
||||
|
||||
TelegramThemeOption(
|
||||
icon = TablerIcons.Moon,
|
||||
title = "Dark",
|
||||
isSelected = themeMode == "dark",
|
||||
onClick = { startThemeReveal("dark", darkOptionCenter) },
|
||||
textColor = textColor,
|
||||
secondaryTextColor = secondaryTextColor,
|
||||
showDivider = true,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onCenterInRootChanged = { darkOptionCenter = it }
|
||||
)
|
||||
|
||||
TelegramThemeOption(
|
||||
icon = TablerIcons.DeviceMobile,
|
||||
title = "System",
|
||||
isSelected = themeMode == "auto",
|
||||
onClick = { startThemeReveal("auto", systemOptionCenter) },
|
||||
textColor = textColor,
|
||||
secondaryTextColor = secondaryTextColor,
|
||||
showDivider = false,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onCenterInRootChanged = { systemOptionCenter = it }
|
||||
)
|
||||
}
|
||||
|
||||
TelegramInfoText(
|
||||
text = "System mode automatically switches between light and dark themes based on your device settings.",
|
||||
secondaryTextColor = secondaryTextColor
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// Content
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// CHAT PREVIEW - Message bubbles like in real chat
|
||||
// ═══════════════════════════════════════════════════════
|
||||
ChatPreview(isDarkTheme = isDarkTheme)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// MODE SELECTOR - Telegram style
|
||||
// ═══════════════════════════════════════════════════════
|
||||
TelegramSectionHeader("Appearance", secondaryTextColor)
|
||||
|
||||
Column {
|
||||
TelegramThemeOption(
|
||||
icon = TablerIcons.Sun,
|
||||
title = "Light",
|
||||
isSelected = themeMode == "light",
|
||||
onClick = {
|
||||
if (themeMode != "light") {
|
||||
themeMode = "light"
|
||||
onThemeModeChange("light")
|
||||
if (themeRevealActive) {
|
||||
val snapshot = themeRevealSnapshot
|
||||
if (snapshot != null) {
|
||||
Canvas(
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.graphicsLayer(
|
||||
compositingStrategy = CompositingStrategy.Offscreen
|
||||
)
|
||||
) {
|
||||
val destinationSize =
|
||||
IntSize(
|
||||
width = size.width.toInt(),
|
||||
height = size.height.toInt()
|
||||
)
|
||||
if (themeRevealToDark) {
|
||||
drawImage(
|
||||
image = snapshot,
|
||||
srcOffset = IntOffset.Zero,
|
||||
srcSize = IntSize(snapshot.width, snapshot.height),
|
||||
dstOffset = IntOffset.Zero,
|
||||
dstSize = destinationSize
|
||||
)
|
||||
drawCircle(
|
||||
color = Color.Transparent,
|
||||
radius = themeRevealRadius.value,
|
||||
center = themeRevealCenter,
|
||||
blendMode = BlendMode.Clear
|
||||
)
|
||||
} else {
|
||||
val radius = themeRevealRadius.value
|
||||
if (radius > 0f) {
|
||||
val clipCirclePath =
|
||||
Path().apply {
|
||||
addOval(
|
||||
Rect(
|
||||
left = themeRevealCenter.x - radius,
|
||||
top = themeRevealCenter.y - radius,
|
||||
right = themeRevealCenter.x + radius,
|
||||
bottom = themeRevealCenter.y + radius
|
||||
)
|
||||
)
|
||||
}
|
||||
clipPath(clipCirclePath) {
|
||||
drawImage(
|
||||
image = snapshot,
|
||||
srcOffset = IntOffset.Zero,
|
||||
srcSize = IntSize(snapshot.width, snapshot.height),
|
||||
dstOffset = IntOffset.Zero,
|
||||
dstSize = destinationSize
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
textColor = textColor,
|
||||
secondaryTextColor = secondaryTextColor,
|
||||
showDivider = true,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
|
||||
TelegramThemeOption(
|
||||
icon = TablerIcons.Moon,
|
||||
title = "Dark",
|
||||
isSelected = themeMode == "dark",
|
||||
onClick = {
|
||||
if (themeMode != "dark") {
|
||||
themeMode = "dark"
|
||||
onThemeModeChange("dark")
|
||||
}
|
||||
},
|
||||
textColor = textColor,
|
||||
secondaryTextColor = secondaryTextColor,
|
||||
showDivider = true,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
|
||||
TelegramThemeOption(
|
||||
icon = TablerIcons.DeviceMobile,
|
||||
title = "System",
|
||||
isSelected = themeMode == "auto",
|
||||
onClick = {
|
||||
if (themeMode != "auto") {
|
||||
themeMode = "auto"
|
||||
onThemeModeChange("auto")
|
||||
}
|
||||
},
|
||||
textColor = textColor,
|
||||
secondaryTextColor = secondaryTextColor,
|
||||
showDivider = false,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TelegramInfoText(
|
||||
text = "System mode automatically switches between light and dark themes based on your device settings.",
|
||||
secondaryTextColor = secondaryTextColor
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,7 +350,8 @@ private fun TelegramThemeOption(
|
||||
textColor: Color,
|
||||
secondaryTextColor: Color,
|
||||
showDivider: Boolean,
|
||||
isDarkTheme: Boolean
|
||||
isDarkTheme: Boolean,
|
||||
onCenterInRootChanged: ((Offset) -> Unit)? = null
|
||||
) {
|
||||
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0)
|
||||
|
||||
@@ -199,6 +360,15 @@ private fun TelegramThemeOption(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.onGloballyPositioned { coords ->
|
||||
val pos = coords.positionInRoot()
|
||||
onCenterInRootChanged?.invoke(
|
||||
Offset(
|
||||
x = pos.x + coords.size.width / 2f,
|
||||
y = pos.y + coords.size.height / 2f
|
||||
)
|
||||
)
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -262,7 +432,9 @@ private fun ChatPreview(isDarkTheme: Boolean) {
|
||||
val otherBubbleColor = if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
|
||||
val myTextColor = Color.White // White text on blue bubble
|
||||
val otherTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val timeColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
|
||||
val myTimeColor = Color.White // White time on blue bubble (matches real chat)
|
||||
val otherTimeColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
val myCheckColor = Color.White
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
@@ -289,7 +461,7 @@ private fun ChatPreview(isDarkTheme: Boolean) {
|
||||
isMe = false,
|
||||
bubbleColor = otherBubbleColor,
|
||||
textColor = otherTextColor,
|
||||
timeColor = timeColor
|
||||
timeColor = otherTimeColor
|
||||
)
|
||||
}
|
||||
|
||||
@@ -304,7 +476,8 @@ private fun ChatPreview(isDarkTheme: Boolean) {
|
||||
isMe = true,
|
||||
bubbleColor = myBubbleColor,
|
||||
textColor = myTextColor,
|
||||
timeColor = timeColor
|
||||
timeColor = myTimeColor,
|
||||
checkmarkColor = myCheckColor
|
||||
)
|
||||
}
|
||||
|
||||
@@ -319,7 +492,7 @@ private fun ChatPreview(isDarkTheme: Boolean) {
|
||||
isMe = false,
|
||||
bubbleColor = otherBubbleColor,
|
||||
textColor = otherTextColor,
|
||||
timeColor = timeColor
|
||||
timeColor = otherTimeColor
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -333,7 +506,8 @@ private fun MessageBubble(
|
||||
isMe: Boolean,
|
||||
bubbleColor: Color,
|
||||
textColor: Color,
|
||||
timeColor: Color
|
||||
timeColor: Color,
|
||||
checkmarkColor: Color = Color(0xFF4FC3F7)
|
||||
) {
|
||||
Surface(
|
||||
color = bubbleColor,
|
||||
@@ -372,7 +546,7 @@ private fun MessageBubble(
|
||||
Icon(
|
||||
painter = TelegramIcons.Done,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF4FC3F7), // Blue checkmarks for read messages
|
||||
tint = checkmarkColor,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user