Update V1.0.6

не придирайтесь к ver name и другим символическим значениям. лень некоторые вещи менять.
This commit is contained in:
amurcanov
2026-04-12 22:14:53 +03:00
parent e3483e15c1
commit 6953665a69
28 changed files with 2940 additions and 1001 deletions
@@ -0,0 +1,187 @@
package com.amurcanov.tgwsproxy.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.amurcanov.tgwsproxy.R
import kotlin.math.roundToInt
@Composable
fun FloatingToolbar(
currentTheme: String,
onThemeChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
val configuration = LocalConfiguration.current
val density = LocalDensity.current
val screenHeightPx = remember(configuration.screenHeightDp, density) {
with(density) { configuration.screenHeightDp.dp.toPx() }
}
val screenWidthPx = remember(configuration.screenWidthDp, density) {
with(density) { configuration.screenWidthDp.dp.toPx() }
}
var offsetY by rememberSaveable { mutableFloatStateOf(screenHeightPx * 0.25f) }
var isRightSide by rememberSaveable { mutableStateOf(true) }
var isExpanded by rememberSaveable { mutableStateOf(false) }
val tabWidthDp = 42.dp
val tabHeightDp = 52.dp
val panelWidthDp = 180.dp
val tabWidthPx = remember(density) { with(density) { tabWidthDp.toPx() } }
val targetXPx = if (isRightSide) screenWidthPx - tabWidthPx else 0f
val animatedTabXPx by animateFloatAsState(
targetValue = targetXPx,
animationSpec = spring(stiffness = Spring.StiffnessLow),
label = "tab_shift"
)
Box(modifier = modifier.fillMaxSize()) {
Surface(
onClick = { isExpanded = !isExpanded },
modifier = Modifier
.offset { IntOffset(animatedTabXPx.roundToInt(), offsetY.roundToInt()) }
.pointerInput(screenWidthPx, screenHeightPx) {
detectDragGestures(
onDrag = { change, dragAmount ->
change.consume()
offsetY = (offsetY + dragAmount.y).coerceIn(0f, screenHeightPx * 0.7f)
}
)
},
shape = if (isRightSide)
RoundedCornerShape(topStart = 14.dp, bottomStart = 14.dp)
else
RoundedCornerShape(topEnd = 14.dp, bottomEnd = 14.dp),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f),
shadowElevation = 6.dp,
tonalElevation = 4.dp,
) {
Box(
modifier = Modifier.size(tabWidthDp, tabHeightDp),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(id = R.drawable.ic_palette),
contentDescription = "Тема",
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
AnimatedVisibility(
visible = isExpanded,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.offset {
val panelWidthPx = with(density) { panelWidthDp.toPx() }
val gap = with(density) { 8.dp.toPx() }
val panelX = if (isRightSide) {
(targetXPx - panelWidthPx - gap).roundToInt()
} else {
(tabWidthPx + gap).roundToInt()
}
IntOffset(panelX, offsetY.roundToInt())
}
) {
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
shadowElevation = 8.dp,
tonalElevation = 4.dp,
) {
Column(
modifier = Modifier.padding(12.dp).width(panelWidthDp - 24.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
"Тема",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(start = 4.dp, bottom = 4.dp)
)
ThemeOption(
icon = R.drawable.ic_auto,
label = "Системная",
selected = currentTheme == "system",
onClick = { onThemeChange("system"); isExpanded = false }
)
ThemeOption(
icon = R.drawable.ic_light_mode,
label = "Светлая",
selected = currentTheme == "light",
onClick = { onThemeChange("light"); isExpanded = false }
)
ThemeOption(
icon = R.drawable.ic_dark_mode,
label = "Тёмная",
selected = currentTheme == "dark",
onClick = { onThemeChange("dark"); isExpanded = false }
)
}
}
}
}
}
@Composable
private fun ThemeOption(
icon: Int,
label: String,
selected: Boolean,
onClick: () -> Unit
) {
Surface(
onClick = onClick,
shape = RoundedCornerShape(14.dp),
color = if (selected) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surface,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
painter = painterResource(id = icon),
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
color = if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
fontSize = 13.sp
)
}
}
}
@@ -0,0 +1,322 @@
package com.amurcanov.tgwsproxy.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.NewReleases
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
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.sp
import com.amurcanov.tgwsproxy.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
private val BROWSER_PACKAGES = listOf(
"com.android.chrome",
"com.google.android.googlequicksearchbox",
"org.mozilla.firefox",
"com.yandex.browser",
"ru.yandex.searchplugin",
"com.yandex.browser.lite",
"com.opera.browser",
"com.opera.mini.native",
"com.microsoft.emmx",
"com.brave.browser",
"com.duckduckgo.mobile.android",
"com.sec.android.app.sbrowser",
"com.vivaldi.browser",
"com.kiwibrowser.browser",
)
private fun openUrlInBrowser(context: Context, url: String) {
try {
val pm = context.packageManager
val uri = Uri.parse(url)
for (pkg in BROWSER_PACKAGES) {
try {
val intent = Intent(Intent.ACTION_VIEW, uri)
intent.setPackage(pkg)
if (intent.resolveActivity(pm) != null) {
context.startActivity(intent)
return
}
} catch (_: Exception) {}
}
val intent = Intent(Intent.ACTION_VIEW, uri)
if (intent.resolveActivity(pm) != null) {
context.startActivity(intent)
}
} catch (_: Exception) {}
}
/**
* Sealed interface for update check result — prevents incorrect state combinations.
*/
private sealed interface UpdateCheckResult {
data object Idle : UpdateCheckResult
data object Loading : UpdateCheckResult
data class UpToDate(val version: String) : UpdateCheckResult
data class NewVersion(val version: String) : UpdateCheckResult
data class Error(val message: String) : UpdateCheckResult
}
/**
* Fetch latest tag from GitHub releases via API.
* Uses redirect-following GET to the tags page on the GitHub API.
*/
private suspend fun checkLatestVersion(): String? = withContext(Dispatchers.IO) {
try {
// Use GitHub API to get latest release / tags
val url = URL("https://api.github.com/repos/amurcanov/tg-ws-proxy-android/tags?per_page=1")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "GET"
conn.setRequestProperty("Accept", "application/vnd.github.v3+json")
conn.connectTimeout = 8000
conn.readTimeout = 8000
if (conn.responseCode == 200) {
val body = conn.inputStream.bufferedReader().readText()
// Parse the first tag name from JSON array: [{"name":"v1.0.6",...}]
val regex = """"name"\s*:\s*"([^"]+)"""".toRegex()
val match = regex.find(body)
match?.groupValues?.get(1)
} else {
null
}
} catch (_: Exception) {
null
}
}
/**
* Compare two version strings like "v1.0.6" and "v1.0.7"
* Returns true if remote is strictly newer than local.
*/
private fun isNewerVersion(local: String, remote: String): Boolean {
val localParts = local.removePrefix("v").split(".").mapNotNull { it.toIntOrNull() }
val remoteParts = remote.removePrefix("v").split(".").mapNotNull { it.toIntOrNull() }
val maxLen = maxOf(localParts.size, remoteParts.size)
for (i in 0 until maxLen) {
val l = localParts.getOrElse(i) { 0 }
val r = remoteParts.getOrElse(i) { 0 }
if (r > l) return true
if (r < l) return false
}
return false
}
@Composable
fun InfoTab() {
val currentVersion = "v1.0.6"
val scope = rememberCoroutineScope()
var updateResult by remember { mutableStateOf<UpdateCheckResult>(UpdateCheckResult.Idle) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Дополнительная информация",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Start
)
// ═══ Версия ═══
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Установлена версия $currentVersion",
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
// ═══ Проверить обновление ═══
Button(
onClick = {
updateResult = UpdateCheckResult.Loading
scope.launch {
val latestTag = checkLatestVersion()
updateResult = if (latestTag != null) {
if (isNewerVersion(currentVersion, latestTag)) {
UpdateCheckResult.NewVersion(latestTag)
} else {
UpdateCheckResult.UpToDate(currentVersion)
}
} else {
UpdateCheckResult.Error("Не удалось проверить")
}
}
},
modifier = Modifier.fillMaxWidth(0.9f).height(48.dp),
shape = RoundedCornerShape(16.dp),
enabled = updateResult !is UpdateCheckResult.Loading,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
) {
when (updateResult) {
is UpdateCheckResult.Loading -> {
Icon(
Icons.Default.Refresh,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Spacer(Modifier.width(8.dp))
Text("Проверяем...", fontWeight = FontWeight.SemiBold)
}
is UpdateCheckResult.UpToDate -> {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = AppColors.terminalGreen
)
Spacer(Modifier.width(8.dp))
Text("Последняя версия ✓", fontWeight = FontWeight.SemiBold)
}
is UpdateCheckResult.NewVersion -> {
val ver = (updateResult as UpdateCheckResult.NewVersion).version
Icon(
Icons.Default.NewReleases,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = AppColors.terminalOrange
)
Spacer(Modifier.width(8.dp))
Text("Вышла $ver", fontWeight = FontWeight.Bold)
}
is UpdateCheckResult.Error -> {
Text("Проверить обновление", fontWeight = FontWeight.SemiBold)
}
is UpdateCheckResult.Idle -> {
Text("Проверить обновление", fontWeight = FontWeight.SemiBold)
}
}
}
}
}
// ═══ GitHub ═══
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Column(
modifier = Modifier.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
GitHubSection(
title = "Актуальные релизы",
buttonText = "tg-ws-proxy-android",
url = "https://github.com/amurcanov/tg-ws-proxy-android/releases"
)
Divider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
GitHubSection(
title = "Страница разработчика",
buttonText = "GitHub Amurcanov",
url = "https://github.com/amurcanov"
)
Divider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
GitHubSection(
title = "Если возникли проблемы",
buttonText = "Поднять вопрос",
url = "https://github.com/amurcanov/tg-ws-proxy-android/issues/new"
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
@Composable
private fun GitHubSection(
title: String,
buttonText: String,
url: String
) {
val context = LocalContext.current
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
)
Button(
onClick = { openUrlInBrowser(context, url) },
modifier = Modifier.fillMaxWidth(0.9f).height(48.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = painterResource(id = R.drawable.ic_github),
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = buttonText,
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold, fontSize = 15.sp)
)
}
}
}
}
@@ -0,0 +1,149 @@
package com.amurcanov.tgwsproxy.ui
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.widget.Toast
import androidx.compose.animation.core.*
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.amurcanov.tgwsproxy.LogEntry
import com.amurcanov.tgwsproxy.LogManager
@Composable
fun LogsTab() {
val context = LocalContext.current
val currentLogs by LogManager.logs.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
LaunchedEffect(currentLogs.size) {
if (currentLogs.isNotEmpty()) {
listState.scrollToItem(currentLogs.size - 1)
}
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Лог событий",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurface
)
Row {
IconButton(onClick = { LogManager.clearLogs() }) {
Icon(Icons.Default.Delete, contentDescription = "Очистить", tint = MaterialTheme.colorScheme.primary)
}
IconButton(onClick = {
val text = currentLogs.joinToString("\n") { "${it.message} (x${it.count})" }
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("TgWsProxy Logs", text)
clipboard.setPrimaryClip(clip)
Toast.makeText(context, "Скопировано", Toast.LENGTH_SHORT).show()
}) {
Icon(Icons.Default.ContentCopy, contentDescription = "Копировать", tint = MaterialTheme.colorScheme.primary)
}
}
}
val isDark = isSystemInDarkTheme()
val terminalBg = if (isDark) AppColors.terminalBgDark else AppColors.terminalBg
Card(
modifier = Modifier.fillMaxSize(),
colors = CardDefaults.cardColors(containerColor = terminalBg),
shape = RoundedCornerShape(20.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().padding(12.dp),
contentPadding = PaddingValues(bottom = 12.dp)
) {
items(currentLogs, key = { it.key }) { entry ->
LogLine(entry)
}
}
}
}
}
@Composable
private fun LogLine(entry: LogEntry) {
val color = when (entry.priority) {
6 -> AppColors.terminalRed // ERROR
5 -> AppColors.terminalOrange // WARN (Нужно убедиться, что Orange есть в AppColors)
4 -> AppColors.terminalGreen // INFO
3 -> AppColors.terminalBlue // DEBUG
else -> AppColors.terminalText
}
var trigger by remember { mutableIntStateOf(0) }
LaunchedEffect(entry.count) { trigger++ }
val animatedScale by animateFloatAsState(
targetValue = if (trigger > 0) 1.15f else 1.0f,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
label = "scale",
finishedListener = { trigger = 0 }
)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
color = AppColors.terminalCounter.copy(alpha = 0.2f),
shape = RoundedCornerShape(16.dp),
modifier = Modifier
.defaultMinSize(minWidth = 24.dp, minHeight = 24.dp)
.graphicsLayer(scaleX = animatedScale, scaleY = animatedScale)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(horizontal = 6.dp)
) {
Text(
text = "${entry.count}",
color = AppColors.terminalBlue,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
maxLines = 1
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = entry.message,
color = color,
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
fontWeight = if (entry.isError) FontWeight.Bold else FontWeight.Normal,
lineHeight = 18.sp,
modifier = Modifier.weight(1f)
)
}
}
@@ -0,0 +1,655 @@
package com.amurcanov.tgwsproxy.ui
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.widget.Toast
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.VpnKey
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.amurcanov.tgwsproxy.ProxyService
import com.amurcanov.tgwsproxy.SettingsStore
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
val telegramApps = listOf(
"org.telegram.messenger",
"org.thunderdog.challegram",
"com.radolyn.ayugram",
"app.exteragram.messenger",
"ir.ilmili.telegraph",
"org.telegram.plus",
"tw.nekomimi.nekogram",
"tw.nekomimi.nekogramx",
"org.telegram.mdgram",
"com.iMe.android",
"app.nicegram",
"org.telegram.bgram",
"cc.modery.cherrygram",
"io.github.nextalone.nagram"
)
private fun generateRandomSecret(): String {
val bytes = ByteArray(16)
java.security.SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
fun openTelegram(context: Context, url: String) {
val pm = context.packageManager
val uri = Uri.parse(url)
for (pkg in telegramApps) {
try {
pm.getPackageInfo(pkg, 0)
val intent = Intent(Intent.ACTION_VIEW, uri)
intent.setPackage(pkg)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
return
} catch (_: PackageManager.NameNotFoundException) {
} catch (_: Exception) {
}
}
try {
val fallbackIntent = Intent(Intent.ACTION_VIEW, uri)
fallbackIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(fallbackIntent)
} catch (_: Exception) {
Toast.makeText(context, "Telegram не найден!", Toast.LENGTH_SHORT).show()
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsTab(settingsStore: SettingsStore) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val isRunning by ProxyService.isRunning.collectAsStateWithLifecycle()
val savedIsDcAuto by settingsStore.isDcAuto.collectAsStateWithLifecycle(initialValue = true)
val savedDc2 by settingsStore.dc2.collectAsStateWithLifecycle(initialValue = "")
val savedDc4 by settingsStore.dc4.collectAsStateWithLifecycle(initialValue = "149.154.167.220")
val savedPort by settingsStore.port.collectAsStateWithLifecycle(initialValue = "1443")
val savedPoolSize by settingsStore.poolSize.collectAsStateWithLifecycle(initialValue = 4)
val savedCfEnabled by settingsStore.cfproxyEnabled.collectAsStateWithLifecycle(initialValue = true)
val savedSecretKey by settingsStore.secretKey.collectAsStateWithLifecycle(initialValue = "LOADING")
var isDcAuto by rememberSaveable(savedIsDcAuto) { mutableStateOf(savedIsDcAuto) }
var dc2Text by rememberSaveable(savedDc2) { mutableStateOf(savedDc2) }
var dc4Text by rememberSaveable(savedDc4) { mutableStateOf(savedDc4) }
var portText by rememberSaveable(savedPort) { mutableStateOf(savedPort) }
var selectedPoolSize by rememberSaveable(savedPoolSize) { mutableIntStateOf(savedPoolSize) }
var cfEnabled by rememberSaveable(savedCfEnabled) { mutableStateOf(savedCfEnabled) }
var secretKeyText by remember(savedSecretKey) { mutableStateOf(if (savedSecretKey == "LOADING") "" else savedSecretKey) }
LaunchedEffect(savedSecretKey) {
if (savedSecretKey == "") {
val generated = generateRandomSecret()
secretKeyText = generated
settingsStore.saveSecretKey(generated)
} else if (savedSecretKey != "LOADING") {
secretKeyText = savedSecretKey
}
}
var saveJob by remember { mutableStateOf<Job?>(null) }
fun scheduleSave() {
saveJob?.cancel()
saveJob = scope.launch {
delay(300)
settingsStore.saveAll(
isDcAuto, dc2Text, dc4Text, portText, selectedPoolSize,
cfEnabled, secretKeyText
)
}
}
var showIpSetupDialog by rememberSaveable { mutableStateOf(false) }
var showHelpDialog by rememberSaveable { mutableStateOf(false) }
val scrollState = rememberScrollState()
if (showIpSetupDialog) {
IpSetupDialog(
isDcAuto = isDcAuto,
onModeChange = { isDcAuto = it; scheduleSave() },
dc2Text = dc2Text,
onDc2Change = { dc2Text = it; scheduleSave() },
dc4Text = dc4Text,
onDc4Change = { dc4Text = it; scheduleSave() },
onDismiss = { showIpSetupDialog = false }
)
}
if (showHelpDialog) {
HelpDialog(onDismiss = { showHelpDialog = false })
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
"Подключение",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold
)
OutlinedTextField(
value = portText,
onValueChange = { portText = it; scheduleSave() },
label = { Text("Порт") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth().height(60.dp),
shape = RoundedCornerShape(14.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
)
OutlinedButton(
onClick = { showIpSetupDialog = true },
modifier = Modifier.fillMaxWidth().height(46.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f))
) {
Icon(Icons.Default.Settings, null, Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Настроить адреса DC", fontWeight = FontWeight.SemiBold)
}
}
}
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Cloud, null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Text(
"CloudFlare",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold
)
}
Switch(
checked = cfEnabled,
onCheckedChange = { cfEnabled = it; scheduleSave() },
enabled = !isRunning
)
}
Text(
if (cfEnabled)
"Трафик проксируется через CloudFlare — улучшает обход блокировок."
else
"Подключение к DC Telegram напрямую.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
lineHeight = 16.sp
)
}
}
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
"Пул WS",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
listOf(4, 6, 8).forEach { size ->
PoolChip(
label = "$size",
selected = selectedPoolSize == size,
enabled = !isRunning,
modifier = Modifier.weight(1f)
) {
selectedPoolSize = size
scheduleSave()
}
}
}
Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.VpnKey, null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Text(
"Секретный ключ",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold
)
}
OutlinedTextField(
value = secretKeyText,
onValueChange = {},
readOnly = true,
singleLine = true,
modifier = Modifier.fillMaxWidth().height(56.dp),
shape = RoundedCornerShape(14.dp),
textStyle = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium),
trailingIcon = {
IconButton(
onClick = {
val newKey = generateRandomSecret()
secretKeyText = newKey
scope.launch { settingsStore.saveSecretKey(newKey) }
scheduleSave()
},
enabled = !isRunning
) {
Icon(Icons.Default.Refresh, null, tint = MaterialTheme.colorScheme.primary)
}
},
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
)
}
}
Spacer(Modifier.height(4.dp))
val buttonColor by animateColorAsState(
targetValue = if (isRunning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
animationSpec = tween(400),
label = "btn_color"
)
Button(
onClick = {
if (isRunning) {
val stopIntent = Intent(context, ProxyService::class.java).apply {
action = ProxyService.ACTION_STOP
}
context.startService(stopIntent)
} else {
val port = portText.toIntOrNull()
if (port == null) {
Toast.makeText(context, "Неверный порт", Toast.LENGTH_SHORT).show()
return@Button
}
val parsedIps = buildList {
if (!isDcAuto) {
if (dc2Text.isNotBlank()) add("2:${dc2Text.trim()}")
if (dc4Text.isNotBlank()) add("4:${dc4Text.trim()}")
}
}.joinToString(",")
saveJob?.cancel()
scope.launch {
settingsStore.saveAll(
isDcAuto, dc2Text, dc4Text, portText, selectedPoolSize,
cfEnabled, secretKeyText
)
}
val startIntent = Intent(context, ProxyService::class.java).apply {
action = ProxyService.ACTION_START
putExtra(ProxyService.EXTRA_PORT, port)
putExtra(ProxyService.EXTRA_IPS, parsedIps)
putExtra(ProxyService.EXTRA_POOL_SIZE, selectedPoolSize)
putExtra(ProxyService.EXTRA_CFPROXY_ENABLED, cfEnabled)
// ProxyService intent expects these even if CF priority is disabled in UI
putExtra(ProxyService.EXTRA_CFPROXY_PRIORITY, true)
putExtra(ProxyService.EXTRA_CFPROXY_DOMAIN, "")
putExtra(ProxyService.EXTRA_SECRET_KEY, secretKeyText.trim())
}
ContextCompat.startForegroundService(context, startIntent)
}
},
modifier = Modifier.fillMaxWidth().height(50.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = buttonColor)
) {
Icon(
imageVector = if (isRunning) Icons.Default.Stop else Icons.Default.PowerSettingsNew,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(8.dp))
Text(
text = if (isRunning) "Остановить" else "Запустить прокси",
fontWeight = FontWeight.Bold
)
}
val port = portText.toIntOrNull() ?: 1443
val secretForUrl = remember(secretKeyText) {
val raw = secretKeyText.trim()
if (raw.isNotEmpty()) raw else "00000000000000000000000000000000"
}
val proxyUrl = "tg://proxy?server=127.0.0.1&port=$port&secret=ee$secretForUrl"
val telegramBtnColor by animateColorAsState(
targetValue = if (isRunning) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
animationSpec = tween(400),
label = "tg_btn_color"
)
Button(
onClick = { openTelegram(context, proxyUrl) },
enabled = isRunning,
modifier = Modifier.fillMaxWidth().height(50.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = telegramBtnColor, contentColor = MaterialTheme.colorScheme.onSurface)
) {
Text("Применить в Telegram", fontWeight = FontWeight.SemiBold)
}
OutlinedButton(
onClick = { showHelpDialog = true },
modifier = Modifier.fillMaxWidth().height(46.dp),
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
) {
Text("Пожалуйста ознакомьтесь!", fontWeight = FontWeight.Medium)
}
Spacer(Modifier.height(8.dp))
}
}
@Composable
private fun PoolChip(
label: String,
selected: Boolean,
enabled: Boolean = true,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
Button(
onClick = onClick,
enabled = enabled,
shape = RoundedCornerShape(50),
modifier = modifier.height(40.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
)
) {
Text(
label,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium
)
}
}
@Composable
private fun HelpDialog(onDismiss: () -> Unit) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 8.dp,
modifier = Modifier.fillMaxWidth(0.95f)
) {
Column(
modifier = Modifier
.padding(24.dp)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text(
"Справка",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
HelpSection(
title = "Адреса датацентров",
text = "Внимание, рекомендую использовать при включенном CloudFlare - Автоматический режим получения DC от самого телеграма. " +
"В случае если вы не пользуетесь CloudFlare или он у вас не работает, переключитесь на ручное использование. " +
"По умолчанию указан DC4 149.154.167.220."
)
Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
HelpSection(
title = "Порт",
text = "Локальный порт прокси. Используйте свободный порт. По умолчанию — 1443."
)
Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
HelpSection(
title = "CloudFlare",
text = "Проксирует трафик через CloudFlare для обхода блокировок. Если Telegram не подключается — отключите."
)
Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
HelpSection(
title = "Пул WS",
text = "Количество фоновых соединений (по умолчанию 4). Увеличьте, если скорость низкая."
)
Divider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
HelpSection(
title = "Секретный ключ",
text = "Ключ шифрования MTProto. Обновляйте только при необходимости."
)
Spacer(Modifier.height(4.dp))
Button(
onClick = onDismiss,
modifier = Modifier.fillMaxWidth().height(46.dp),
shape = RoundedCornerShape(16.dp)
) {
Text("Понятно", fontWeight = FontWeight.SemiBold)
}
}
}
}
}
@Composable
private fun HelpSection(title: String, text: String) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
lineHeight = 20.sp
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun IpSetupDialog(
isDcAuto: Boolean, onModeChange: (Boolean) -> Unit,
dc2Text: String, onDc2Change: (String) -> Unit,
dc4Text: String, onDc4Change: (String) -> Unit,
onDismiss: () -> Unit
) {
val onIpChange = { newValue: String, update: (String) -> Unit ->
if (newValue.all { it.isDigit() || it == '.' }) {
update(newValue)
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 8.dp,
modifier = Modifier.fillMaxWidth(0.95f)
) {
Column(
modifier = Modifier
.padding(24.dp)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
"Адреса датацентров",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.1f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (isDcAuto) "Авто DC от Telegram" else "Ручные DC",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
Switch(
checked = isDcAuto,
onCheckedChange = { onModeChange(it) }
)
}
}
if (!isDcAuto) {
@Composable
fun dcInput(label: String, value: String, update: (String) -> Unit) {
Text(
label,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
OutlinedTextField(
value = value,
onValueChange = { onIpChange(it, update) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth().height(52.dp),
shape = RoundedCornerShape(16.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
)
}
dcInput("DC2", dc2Text, onDc2Change)
dcInput("DC4", dc4Text, onDc4Change)
}
Spacer(Modifier.height(4.dp))
Button(
onClick = onDismiss,
modifier = Modifier.fillMaxWidth().height(46.dp),
shape = RoundedCornerShape(16.dp)
) {
Text("Готово", fontWeight = FontWeight.SemiBold)
}
}
}
}
}
@@ -0,0 +1,173 @@
package com.amurcanov.tgwsproxy.ui
import android.os.Build
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import com.amurcanov.tgwsproxy.R
// ═══ Inter Font Family ═══
val InterFontFamily = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal),
Font(R.font.inter_medium, FontWeight.Medium),
Font(R.font.inter_semibold, FontWeight.SemiBold),
Font(R.font.inter_bold, FontWeight.Bold),
)
// ═══ Типография на Inter ═══
val TgWsProxyTypography = Typography(
displayLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Bold, fontSize = 57.sp, lineHeight = 64.sp),
displayMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Bold, fontSize = 45.sp, lineHeight = 52.sp),
displaySmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Bold, fontSize = 36.sp, lineHeight = 44.sp),
headlineLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 32.sp, lineHeight = 40.sp),
headlineMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 28.sp, lineHeight = 36.sp),
headlineSmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 32.sp),
titleLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 28.sp),
titleMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp),
titleSmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp),
bodyLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.5.sp),
bodyMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.25.sp),
bodySmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp),
labelLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp),
labelMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp),
labelSmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp),
)
// ═══ Светлая палитра — «Раф на кокосовом молоке» ═══
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF6D4C41),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFD7CCC8),
onPrimaryContainer = Color(0xFF3E2723),
secondary = Color(0xFF8D6E63),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFEFEBE9),
onSecondaryContainer = Color(0xFF4E342E),
tertiary = Color(0xFF795548),
onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFBCAAA4),
onTertiaryContainer = Color(0xFF3E2723),
background = Color(0xFFFFFBF7),
onBackground = Color(0xFF1C1B1A),
surface = Color(0xFFF5F0EB),
onSurface = Color(0xFF1C1B1A),
surfaceVariant = Color(0xFFEFEBE9),
onSurfaceVariant = Color(0xFF5D4037),
outline = Color(0xFFBCAAA4),
outlineVariant = Color(0xFFD7CCC8),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
inverseSurface = Color(0xFF322F2D),
inverseOnSurface = Color(0xFFF5F0EB),
inversePrimary = Color(0xFFD7CCC8),
surfaceTint = Color(0xFF6D4C41),
)
// ═══ Тёмная палитра — «Эспрессо» ═══
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFFD7CCC8),
onPrimary = Color(0xFF3E2723),
primaryContainer = Color(0xFF5D4037),
onPrimaryContainer = Color(0xFFEFEBE9),
secondary = Color(0xFFBCAAA4),
onSecondary = Color(0xFF3E2723),
secondaryContainer = Color(0xFF4E342E),
onSecondaryContainer = Color(0xFFEFEBE9),
tertiary = Color(0xFFA1887F),
onTertiary = Color(0xFF3E2723),
tertiaryContainer = Color(0xFF5D4037),
onTertiaryContainer = Color(0xFFEFEBE9),
background = Color(0xFF1A1614),
onBackground = Color(0xFFEDE0D4),
surface = Color(0xFF211D1B),
onSurface = Color(0xFFEDE0D4),
surfaceVariant = Color(0xFF2C2624),
onSurfaceVariant = Color(0xFFD7CCC8),
outline = Color(0xFF8D6E63),
outlineVariant = Color(0xFF4E342E),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
inverseSurface = Color(0xFFEDE0D4),
inverseOnSurface = Color(0xFF322F2D),
inversePrimary = Color(0xFF6D4C41),
surfaceTint = Color(0xFFD7CCC8),
)
// ═══ Расширенные цвета для кастомных элементов ═══
object AppColors {
val connected = Color(0xFF4CAF50)
val connectedContainer = Color(0xFF4CAF50).copy(alpha = 0.12f)
val onConnected = Color(0xFF1B5E20)
val connectedDark = Color(0xFF81C784)
val connectedContainerDark = Color(0xFF81C784).copy(alpha = 0.15f)
val onConnectedDark = Color(0xFFC8E6C9)
val warning = Color(0xFFFFA726)
val warningDark = Color(0xFFFFCC80)
val terminalBg = Color(0xFF1A1A2E)
val terminalBgDark = Color(0xFF0D0D1A)
val terminalText = Color(0xFFE0E0E0)
val terminalGreen = Color(0xFF4CAF50)
val terminalBlue = Color(0xFF42A5F5)
val terminalRed = Color(0xFFEF5350)
val terminalOrange = Color(0xFFFF7043)
val terminalYellow = Color(0xFFFFC107)
val terminalCounter = Color(0xFF1E88E5)
val github = Color(0xFF24292E)
val githubDark = Color(0xFF333C47)
val donate = Color(0xFF8B3FFD)
}
@Composable
fun TgWsProxyTheme(
themeMode: String = "system",
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val darkTheme = when (themeMode) {
"dark" -> true
"light" -> false
else -> isSystemInDarkTheme()
}
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = TgWsProxyTypography,
content = content
)
}