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:
parent
fadb0870e8
commit
ef165b6b9b
4 changed files with 442 additions and 10 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
343
app/src/main/java/chat/vojo/proxy/ui/SitesScreen.kt
Normal file
343
app/src/main/java/chat/vojo/proxy/ui/SitesScreen.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue