Промежуточный результат для 1.0.4 версии

This commit is contained in:
2026-02-22 08:54:46 +05:00
parent 3aa18fa9ac
commit 5b9b3f83f7
37 changed files with 5643 additions and 928 deletions

View File

@@ -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 {

View File

@@ -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
)
}
}

View File

@@ -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))

View File

@@ -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)
)
}