feat(ui): add Сайты tab — mode toggle, domain input, geosite search

Live geosite category suggestions; edits persist and hot-restart. Shrinks tab padding/size so five tabs fit.
This commit is contained in:
heaven 2026-06-16 16:04:32 +03:00
parent fadb0870e8
commit ef165b6b9b
4 changed files with 442 additions and 10 deletions

View file

@ -74,12 +74,14 @@ fun SectionLabel(text: String, modifier: Modifier = Modifier) {
}
/**
* The whole top chrome: the four tabs sit on the static backdrop (the row does
* NOT move with the panes). Tabs are sized to their content and distributed with
* even gaps equal-width quarters left the centred labels with ragged spacing.
* The active tab carries a violet underline that slides AND resizes between the
* measured tab bounds; both the bounds and the pager position are read in the
* layout phase so a swipe glides the line without recomposing the row.
* The whole top chrome: the tabs sit on the static backdrop (the row does NOT move
* with the panes). Tabs are sized to their content and distributed with even gaps
* equal-width slots left the centred labels with ragged spacing. Padding/size are
* tuned so all five labels fit a phone width without clipping (adding "Сайты" pushed
* the content past the edge at the old 14dp/14.5sp). The active tab carries a violet
* underline that slides AND resizes between the measured tab bounds; both the bounds
* and the pager position are read in the layout phase so a swipe glides the line
* without recomposing the row.
*/
@Composable
fun SegmentTabs(
@ -113,13 +115,13 @@ fun SegmentTabs(
.fillMaxHeight()
.clip(RoundedCornerShape(10.dp))
.clickable { onSelect(i) }
.padding(horizontal = 14.dp),
.padding(horizontal = 9.dp),
contentAlignment = Alignment.Center,
) {
Text(
label,
color = if (active) Vojo.text else Vojo.muted,
fontSize = 14.5.sp,
fontSize = 13.sp,
fontWeight = if (active) FontWeight.SemiBold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,

View file

@ -50,8 +50,10 @@ fun MainScreen(
val applied by vm.applied.collectAsState()
val apps by vm.apps.collectAsState()
val appsLoading by vm.appsLoading.collectAsState()
val geositeSuggestions by vm.geositeSuggestions.collectAsState()
val geositeLoading by vm.geositeLoading.collectAsState()
val tabs = listOf("Туннель", "Серверы", "Приложения", "Опции")
val tabs = listOf("Туннель", "Серверы", "Приложения", "Сайты", "Опции")
val pagerState = rememberPagerState(pageCount = { tabs.size })
val scope = rememberCoroutineScope()
@ -151,7 +153,19 @@ fun MainScreen(
onClear = { vm.clearApps() },
onLoad = { vm.loadApps() },
)
3 -> SettingsScreen(
3 -> SitesScreen(
routedSites = settings.routedSites,
routingMode = settings.routingMode,
categorySuggestions = geositeSuggestions,
categoryLoading = geositeLoading,
onCategoryQuery = { vm.setGeositeQuery(it) },
onAddSite = { vm.addSite(it) },
onRemoveSite = { vm.removeSite(it) },
onToggleCategory = { cat, on -> vm.toggleCategory(cat, on) },
onSetMode = { vm.setRoutingMode(it) },
onClear = { vm.clearSites() },
)
4 -> SettingsScreen(
settings = settings,
onChange = { transform -> vm.updateSettings(transform) },
)

View file

@ -6,18 +6,28 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import chat.vojo.proxy.core.AppSettings
import chat.vojo.proxy.core.ProxyConfig
import chat.vojo.proxy.core.RoutingMode
import chat.vojo.proxy.core.route.GEOSITE_PREFIX
import chat.vojo.proxy.core.route.GeositeHit
import chat.vojo.proxy.data.AppInfo
import chat.vojo.proxy.data.AppRepository
import chat.vojo.proxy.data.ConfigStore
import chat.vojo.proxy.data.GeositeRepository
import chat.vojo.proxy.data.ProxyState
import chat.vojo.proxy.service.ProxyVpnService
import chat.vojo.proxy.service.VpnState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainViewModel(app: Application) : AndroidViewModel(app) {
@ -84,6 +94,69 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
reconnectIfRunning()
}
// --- Сайты (domain routing) ---
// Each mutation persists then re-applies the tunnel live (mapped-DNS / forced-IPv4 are
// decided at establish()/buildEngineConfig() time), mirroring the per-app toggle path.
fun addSite(domain: String) = viewModelScope.launch {
store.updateSettings { it.copy(routedSites = it.routedSites + domain) }
reconnectIfRunning()
}
fun removeSite(entry: String) = viewModelScope.launch {
store.updateSettings { it.copy(routedSites = it.routedSites - entry) }
reconnectIfRunning()
}
fun toggleCategory(category: String, enabled: Boolean) = viewModelScope.launch {
val key = GEOSITE_PREFIX + category
store.updateSettings { s ->
s.copy(routedSites = if (enabled) s.routedSites + key else s.routedSites - key)
}
reconnectIfRunning()
}
fun clearSites() = viewModelScope.launch {
store.updateSettings { it.copy(routedSites = emptySet()) }
reconnectIfRunning()
}
fun setRoutingMode(mode: RoutingMode) = viewModelScope.launch {
store.updateSettings { it.copy(routingMode = mode) }
reconnectIfRunning()
}
// --- Geosite category search (full bundled DB, parsed lazily off the main thread) ---
private val _geositeQuery = MutableStateFlow("")
private val _geositeLoading = MutableStateFlow(false)
val geositeLoading: StateFlow<Boolean> = _geositeLoading.asStateFlow()
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val geositeSuggestions: StateFlow<List<GeositeHit>> = _geositeQuery
.debounce(200)
.mapLatest { q ->
if (q.isBlank()) {
emptyList()
} else {
_geositeLoading.value = true
try {
// First call parses the 2.2 MB asset (~hundreds of ms) — keep it off the UI thread.
withContext(Dispatchers.Default) { GeositeRepository.search(getApplication(), q, 40) }
} finally {
_geositeLoading.value = false
}
}
}
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
fun setGeositeQuery(query: String) {
// Flip the spinner on synchronously (before debounce + the first parse) so the UI shows
// "loading" instead of flashing "ничего не найдено" during the debounce window.
_geositeLoading.value = query.isNotBlank()
_geositeQuery.value = query
}
private fun reconnectIfRunning() {
if (VpnState.status.value in RUNNING_STATES) ProxyVpnService.restart(getApplication())
}

View file

@ -0,0 +1,343 @@
package chat.vojo.proxy.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.vojo.proxy.core.RoutingMode
import chat.vojo.proxy.core.route.GEOSITE_PREFIX
import chat.vojo.proxy.core.route.GeositeHit
import chat.vojo.proxy.core.route.normalizeDomain
import chat.vojo.proxy.ui.theme.Vojo
/**
* Domain split-routing ("Сайты"). A global [routingMode] default plus an exception list:
* users type suffix domains or pick geosite categories via live search over the bundled
* database. Changes persist and hot-restart the tunnel via the view-model (mapped-DNS is
* rebuilt on restart).
*/
@Composable
fun SitesScreen(
routedSites: Set<String>,
routingMode: RoutingMode,
categorySuggestions: List<GeositeHit>,
categoryLoading: Boolean,
onCategoryQuery: (String) -> Unit,
onAddSite: (String) -> Unit,
onRemoveSite: (String) -> Unit,
onToggleCategory: (String, Boolean) -> Unit,
onSetMode: (RoutingMode) -> Unit,
onClear: () -> Unit,
) {
var domainInput by rememberSaveable { mutableStateOf("") }
var catQuery by rememberSaveable { mutableStateOf("") }
// catQuery is saved across process death but the VM's query isn't — re-seed it so restored
// search text shows results instead of a stale "ничего не найдено".
LaunchedEffect(Unit) { if (catQuery.isNotBlank()) onCategoryQuery(catQuery) }
val normalized = remember(domainInput) { normalizeDomain(domainInput) }
val showInvalid = domainInput.isNotBlank() && normalized == null
val domains = remember(routedSites) {
routedSites.filterNot { it.startsWith(GEOSITE_PREFIX) }.sorted()
}
val selectedCategories = remember(routedSites) {
routedSites.filter { it.startsWith(GEOSITE_PREFIX) }.map { it.removePrefix(GEOSITE_PREFIX) }.sorted()
}
val selectedCatSet = remember(selectedCategories) { selectedCategories.toSet() }
fun commitDomain() {
val d = normalizeDomain(domainInput) ?: return
onAddSite(d)
domainInput = ""
}
Column(Modifier.fillMaxSize()) {
Column(Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Маршрутизация по доменам", color = Vojo.text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
if (routedSites.isNotEmpty()) {
Spacer(Modifier.width(8.dp))
Box(Modifier.clip(RoundedCornerShape(99.dp)).background(Vojo.fleet).padding(horizontal = 8.dp, vertical = 2.dp)) {
Text("${routedSites.size}", color = Color(0xFF0C0C0E), fontSize = 11.sp, fontWeight = FontWeight.Bold)
}
}
Spacer(Modifier.weight(1f))
if (routedSites.isNotEmpty()) {
Text(
"Сбросить",
color = Vojo.fleetSoft, fontSize = 13.sp,
modifier = Modifier.clip(RoundedCornerShape(8.dp)).clickable { onClear() }.padding(horizontal = 10.dp, vertical = 8.dp),
)
}
}
Spacer(Modifier.height(12.dp))
ModeToggle(routingMode, onSetMode)
Spacer(Modifier.height(6.dp))
Text(
when (routingMode) {
RoutingMode.PROXY_ALL -> "Сайты из списка идут напрямую, остальной трафик — через прокси."
RoutingMode.DIRECT_ALL -> "Сайты из списка идут через прокси, остальной трафик — напрямую."
},
color = Vojo.faint, fontSize = 12.sp,
)
Spacer(Modifier.height(12.dp))
// Manual suffix domain (example.com covers subdomains).
InputField(
value = domainInput,
onValueChange = { domainInput = it },
placeholder = "Добавить домен, напр. example.com",
mono = true,
invalid = showInvalid,
keyboardType = KeyboardType.Uri,
onSubmit = { commitDomain() },
trailing = {
val canAdd = normalized != null
Box(
Modifier.size(34.dp).clip(RoundedCornerShape(9.dp)).background(if (canAdd) Vojo.fleet else Vojo.surface)
.then(if (canAdd) Modifier.clickable { commitDomain() } else Modifier),
contentAlignment = Alignment.Center,
) {
Icon(VojoIcons.Plus, contentDescription = "Добавить", tint = if (canAdd) Color(0xFF0C0C0E) else Vojo.faint, modifier = Modifier.size(16.dp))
}
},
)
if (showInvalid) {
Spacer(Modifier.height(4.dp))
Text("Не похоже на домен", color = Vojo.danger, fontSize = 11.sp)
}
Spacer(Modifier.height(10.dp))
// Geosite category search (live suggestions over the bundled database).
InputField(
value = catQuery,
onValueChange = { catQuery = it; onCategoryQuery(it) },
placeholder = "Найти категорию: google, netflix, ads…",
mono = false,
invalid = false,
keyboardType = KeyboardType.Text,
onSubmit = {},
leading = { Icon(VojoIcons.Search, contentDescription = null, tint = Vojo.faint, modifier = Modifier.size(17.dp)) },
trailing = if (catQuery.isNotEmpty()) {
{
Box(
Modifier.size(30.dp).clip(RoundedCornerShape(8.dp)).clickable { catQuery = ""; onCategoryQuery("") },
contentAlignment = Alignment.Center,
) { Icon(VojoIcons.Cross, contentDescription = "Очистить", tint = Vojo.muted, modifier = Modifier.size(14.dp)) }
}
} else null,
)
}
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 14.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
if (catQuery.isNotBlank()) {
// Search mode: show suggestions from the geosite DB.
item(key = "hdr-sug") { SectionLabel("Категории geosite", Modifier.padding(start = 6.dp, top = 6.dp, bottom = 6.dp)) }
if (categoryLoading && categorySuggestions.isEmpty()) {
item(key = "sug-loading") {
Box(Modifier.fillMaxWidth().padding(vertical = 24.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = Vojo.fleet, strokeWidth = 2.dp, modifier = Modifier.size(22.dp))
}
}
} else if (categorySuggestions.isEmpty()) {
item(key = "sug-empty") {
Text("Ничего не найдено по «$catQuery»", color = Vojo.faint, fontSize = 13.sp, modifier = Modifier.padding(start = 6.dp, top = 10.dp, bottom = 10.dp))
}
} else {
items(categorySuggestions, key = { "sug:${it.category}" }) { hit ->
CategoryRow(
category = hit.category,
ruleCount = hit.ruleCount,
byName = hit.matchedByName,
checked = hit.category in selectedCatSet,
onToggle = { on -> onToggleCategory(hit.category, on) },
)
}
}
} else {
// Browse mode: selected categories + manual domains.
item(key = "hdr-cat") {
SectionLabel(if (selectedCategories.isEmpty()) "Категории" else "Категории · ${selectedCategories.size}", Modifier.padding(start = 6.dp, top = 6.dp, bottom = 6.dp))
}
if (selectedCategories.isEmpty()) {
item(key = "cat-empty") {
Text("Найдите категорию выше (google, netflix, telegram…) и добавьте её.", color = Vojo.faint, fontSize = 12.sp, modifier = Modifier.padding(start = 6.dp, top = 4.dp, bottom = 8.dp))
}
}
items(selectedCategories, key = { "selcat:$it" }) { cat ->
CategoryRow(category = cat, ruleCount = null, byName = true, checked = true) { onToggleCategory(cat, false) }
}
item(key = "hdr-dom") {
SectionLabel(if (domains.isEmpty()) "Домены" else "Домены · ${domains.size}", Modifier.padding(start = 6.dp, top = 14.dp, bottom = 6.dp))
}
if (domains.isEmpty()) {
item(key = "dom-empty") {
Text("Добавьте домен выше — он и все его поддомены попадут под правило.", color = Vojo.faint, fontSize = 12.sp, modifier = Modifier.padding(start = 6.dp, top = 4.dp, bottom = 8.dp))
}
}
items(domains, key = { "dom:$it" }) { domain ->
DomainRow(domain) { onRemoveSite(domain) }
}
}
}
}
}
/** Shared boxed text field matching the Apps search composer. */
@Composable
private fun InputField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
mono: Boolean,
invalid: Boolean,
keyboardType: KeyboardType,
onSubmit: () -> Unit,
leading: (@Composable () -> Unit)? = null,
trailing: (@Composable () -> Unit)? = null,
) {
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(Vojo.bgPanel)
.border(1.dp, if (invalid) Vojo.danger.copy(alpha = 0.55f) else Vojo.divider, RoundedCornerShape(12.dp))
.padding(start = 14.dp, end = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (leading != null) { leading(); Spacer(Modifier.width(10.dp)) }
BasicTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
modifier = Modifier.weight(1f),
textStyle = TextStyle(color = Vojo.text, fontSize = 15.sp, fontFamily = if (mono) Vojo.mono else androidx.compose.ui.text.font.FontFamily.Default),
cursorBrush = SolidColor(Vojo.fleet),
keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { onSubmit() }),
decorationBox = { inner ->
Box(Modifier.padding(vertical = 14.dp)) {
if (value.isEmpty()) Text(placeholder, color = Vojo.faint, fontSize = 15.sp, fontFamily = if (mono) Vojo.mono else androidx.compose.ui.text.font.FontFamily.Default, maxLines = 1, overflow = TextOverflow.Ellipsis)
inner()
}
},
)
if (trailing != null) { Spacer(Modifier.width(6.dp)); trailing() }
}
}
@Composable
private fun ModeToggle(mode: RoutingMode, onSet: (RoutingMode) -> Unit) {
Row(
Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(Vojo.bgPanel).border(1.dp, Vojo.divider, RoundedCornerShape(12.dp)).padding(3.dp),
) {
ModePill("Всё через прокси", mode == RoutingMode.PROXY_ALL, Modifier.weight(1f)) { onSet(RoutingMode.PROXY_ALL) }
ModePill("Всё напрямую", mode == RoutingMode.DIRECT_ALL, Modifier.weight(1f)) { onSet(RoutingMode.DIRECT_ALL) }
}
}
@Composable
private fun ModePill(text: String, active: Boolean, modifier: Modifier, onClick: () -> Unit) {
Box(
modifier.clip(RoundedCornerShape(9.dp)).background(if (active) Vojo.fleet else Color.Transparent).clickable { onClick() }.padding(vertical = 9.dp),
contentAlignment = Alignment.Center,
) {
Text(text, color = if (active) Color(0xFF0C0C0E) else Vojo.muted, fontSize = 13.sp, fontWeight = if (active) FontWeight.SemiBold else FontWeight.Medium)
}
}
@Composable
private fun CategoryRow(category: String, ruleCount: Int?, byName: Boolean, checked: Boolean, onToggle: (Boolean) -> Unit) {
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(if (checked) Vojo.surface else Color.Transparent)
.clickable { onToggle(!checked) }
.padding(horizontal = 10.dp, vertical = 11.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.width(2.dp).height(28.dp).clip(RoundedCornerShape(1.dp)).background(if (checked) Vojo.fleet else Color.Transparent))
Spacer(Modifier.width(11.dp))
Column(Modifier.weight(1f)) {
Text(category, color = Vojo.text, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
val sub = buildString {
append("geosite")
if (ruleCount != null) append(" · ${ruleCount} доменов")
if (!byName) append(" · содержит домен")
}
Text(sub, color = Vojo.faint, fontFamily = Vojo.mono, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Spacer(Modifier.width(10.dp))
SquareCheck(checked)
}
}
@Composable
private fun DomainRow(domain: String, onRemove: () -> Unit) {
Row(
Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(Vojo.surface).padding(horizontal = 10.dp, vertical = 11.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.width(2.dp).height(28.dp).clip(RoundedCornerShape(1.dp)).background(Vojo.fleet))
Spacer(Modifier.width(11.dp))
Text(domain, color = Vojo.text, fontFamily = Vojo.mono, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
Spacer(Modifier.width(10.dp))
Box(Modifier.size(28.dp).clip(RoundedCornerShape(8.dp)).clickable { onRemove() }, contentAlignment = Alignment.Center) {
Icon(VojoIcons.Cross, contentDescription = "Удалить", tint = Vojo.muted, modifier = Modifier.size(14.dp))
}
}
}
@Composable
private fun SquareCheck(checked: Boolean) {
Box(
Modifier.size(24.dp).clip(RoundedCornerShape(7.dp)).background(if (checked) Vojo.fleet else Color.Transparent).border(1.dp, if (checked) Vojo.fleet else Vojo.hairline, RoundedCornerShape(7.dp)),
contentAlignment = Alignment.Center,
) {
if (checked) Icon(VojoIcons.Check, contentDescription = null, tint = Color(0xFF0C0C0E), modifier = Modifier.size(14.dp))
}
}