feat(ui): move connect/disconnect into the server card; replace sent/received/time tiles with a live throughput graph

This commit is contained in:
heaven 2026-06-16 21:39:53 +03:00
parent 15cdd397cb
commit 5cfb893d6e
5 changed files with 361 additions and 106 deletions

View file

@ -16,6 +16,13 @@ fun formatBytes(b: Long, units: List<String>): String {
return String.format(Locale.US, "%.1f %s", v, units[i])
}
/**
* Per-second throughput, e.g. `1.2 MB/s`. Reuses [formatBytes] for the value+unit and
* appends the localized [perSecond] suffix (`/s` / `/с`, from `R.string.rate_per_second`).
*/
fun formatRate(bytesPerSec: Long, units: List<String>, perSecond: String): String =
formatBytes(bytesPerSec, units) + perSecond
fun formatUptime(since: Long, nowMs: Long): String {
if (since <= 0) return ""
val s = ((nowMs - since) / 1000).coerceAtLeast(0)

View file

@ -1,15 +1,18 @@
package chat.vojo.proxy.ui
import android.annotation.SuppressLint
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
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
@ -33,17 +36,24 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
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.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@ -51,19 +61,18 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.compose.ui.util.lerp
import chat.vojo.proxy.R
import chat.vojo.proxy.core.AppSettings
import chat.vojo.proxy.core.ProxyConfig
import chat.vojo.proxy.core.ProxyType
import chat.vojo.proxy.core.formatBytes
import chat.vojo.proxy.core.formatClock
import chat.vojo.proxy.core.formatUptime
import chat.vojo.proxy.core.formatRate
import chat.vojo.proxy.service.VpnState
import chat.vojo.proxy.ui.theme.Vojo
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import java.util.Calendar
import kotlin.math.sqrt
private fun protoColor(config: ProxyConfig?): Color = when (config?.type) {
ProxyType.SHADOWSOCKS -> Vojo.fleet
@ -94,8 +103,7 @@ fun ConnectionScreen(
activeConfig: ProxyConfig?,
configs: List<ProxyConfig>,
status: VpnState.Status,
stats: VpnState.Stats,
connectedSince: Long,
ratesFlow: StateFlow<VpnState.RateHistory>,
error: String?,
events: List<VpnState.TunnelEvent>,
applied: VpnState.Applied?,
@ -121,9 +129,10 @@ fun ConnectionScreen(
ActiveServerCard(
config = activeConfig,
status = status,
live = live,
active = active,
onPick = if (configs.size > 1 && !active) ({ pickerOpen = true }) else null,
onConnect = onConnect,
onDisconnect = onDisconnect,
)
@ -145,16 +154,10 @@ fun ConnectionScreen(
Spacer(Modifier.height(16.dp))
// Fixed-height slot: button when down, stats when up — swapping in
// place means the journal below never jumps on connect/disconnect.
Box(Modifier.fillMaxWidth().height(72.dp), contentAlignment = Alignment.Center) {
when {
connected -> StatsRow(stats, connectedSince)
status == VpnState.Status.CONNECTING || status == VpnState.Status.STOPPING ->
ConnectingButton(status)
else -> ConnectButton(status, onConnect, enabled = activeConfig != null)
}
}
// The throughput graph replaces the old connect-button / stats slot. It keeps a
// fixed height in every state (idle frame when down, live curve when up) so the
// journal below never jumps on connect/disconnect.
ThroughputCard(ratesFlow = ratesFlow, status = status)
}
// ── Scrollable journal — the only thing that scrolls; newest on top ──
@ -197,9 +200,10 @@ private fun ErrorBanner(message: String) {
@Composable
private fun ActiveServerCard(
config: ProxyConfig?,
status: VpnState.Status,
live: Boolean,
active: Boolean,
onPick: (() -> Unit)?,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
) {
PanelCard(
@ -225,28 +229,74 @@ private fun ActiveServerCard(
}
}
Spacer(Modifier.height(3.dp))
Text(
config?.handle ?: stringResource(R.string.conn_choose_server),
color = Vojo.muted, fontFamily = Vojo.mono, fontSize = 13.sp,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
if (active) {
// Disconnect lives inside the card as a square ✕.
Spacer(Modifier.width(10.dp))
Box(
Modifier
.size(46.dp)
.clip(RoundedCornerShape(12.dp))
.background(Vojo.danger.copy(alpha = 0.14f))
.clickable { onDisconnect() },
contentAlignment = Alignment.Center,
) {
Icon(VojoIcons.Cross, contentDescription = stringResource(R.string.action_disconnect), tint = Vojo.danger, modifier = Modifier.size(18.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
config?.handle ?: stringResource(R.string.conn_choose_server),
color = Vojo.muted, fontFamily = Vojo.mono, fontSize = 13.sp,
maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
// Subtle "tap the card to change server" hint — only when a picker exists.
if (onPick != null) {
Spacer(Modifier.width(5.dp))
Icon(VojoIcons.ChevronDown, contentDescription = null, tint = Vojo.faint, modifier = Modifier.size(14.dp))
}
}
} else if (config != null && onPick != null) {
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.conn_change_server_short), color = Vojo.fleetSoft, fontSize = 13.sp)
}
// The connect/disconnect action now lives inside the card, in the same
// 46dp slot in every state (connect ▶ / spinner / disconnect ✕).
Spacer(Modifier.width(10.dp))
ConnToggle(
status = status,
hasConfig = config != null,
onConnect = onConnect,
onDisconnect = onDisconnect,
)
}
}
}
/** The in-card primary action: connect ▶ when down, a spinner mid-transition, disconnect ✕ when up. */
@Composable
private fun ConnToggle(
status: VpnState.Status,
hasConfig: Boolean,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
) {
val connected = status == VpnState.Status.CONNECTED
val transition = status == VpnState.Status.CONNECTING || status == VpnState.Status.STOPPING
val bgTarget = when {
connected -> Vojo.danger.copy(alpha = 0.14f)
transition -> Vojo.surface
hasConfig -> Vojo.fleet.copy(alpha = 0.14f)
else -> Vojo.surface
}
val bg by animateColorAsState(bgTarget, tween(180), label = "conn-toggle-bg")
val onTap: (() -> Unit)? = when {
connected -> onDisconnect
transition -> null
hasConfig -> onConnect
else -> null
}
var box = Modifier
.size(46.dp)
.clip(RoundedCornerShape(12.dp))
.background(bg)
if (onTap != null) box = box.clickable { onTap() }
Box(box, contentAlignment = Alignment.Center) {
Crossfade(targetState = status, animationSpec = tween(180), label = "conn-toggle-icon") { st ->
when (st) {
VpnState.Status.CONNECTED ->
Icon(VojoIcons.Cross, contentDescription = stringResource(R.string.action_disconnect), tint = Vojo.danger, modifier = Modifier.size(18.dp))
VpnState.Status.CONNECTING, VpnState.Status.STOPPING ->
CircularProgressIndicator(
color = if (st == VpnState.Status.STOPPING) Vojo.muted else Vojo.fleetSoft,
strokeWidth = 2.dp,
modifier = Modifier.size(16.dp),
)
else ->
Icon(VojoIcons.Play, contentDescription = stringResource(R.string.conn_connect), tint = if (hasConfig) Vojo.fleet else Vojo.faint, modifier = Modifier.size(18.dp))
}
}
}
@ -308,79 +358,268 @@ private fun ServerPickerSheet(
}
}
@Composable
private fun ConnectButton(status: VpnState.Status, onConnect: () -> Unit, enabled: Boolean) {
val bg = if (enabled) Vojo.fleet else Vojo.surface
val fg = if (enabled) Color(0xFF0C0C0E) else Vojo.faint
Box(
Modifier
.fillMaxWidth()
.height(58.dp)
.clip(RoundedCornerShape(15.dp))
.background(bg)
.clickable(enabled = enabled) { onConnect() },
contentAlignment = Alignment.Center,
) {
Text(stringResource(R.string.conn_connect), color = fg, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
}
}
/** Floor for the graph's y-scale (64 KiB/s) so idle/low traffic never looks huge. */
private const val RATE_FLOOR = 65536
/** Non-interactive transition state shown while the tunnel comes up or tears down. */
/**
* The live throughput card. Fixed 116dp height in every state so the journal below never
* jumps. Split into two leaves (legend text + Canvas graph), each collecting [ratesFlow]
* itself the flow is passed RAW from MainScreen so the pager never recomposes per sample.
*/
@Composable
private fun ConnectingButton(status: VpnState.Status) {
val label = if (status == VpnState.Status.STOPPING) stringResource(R.string.conn_disconnecting) else stringResource(R.string.notif_connecting)
Box(
Modifier
.fillMaxWidth()
.height(58.dp)
.clip(RoundedCornerShape(15.dp))
.background(Vojo.surface),
contentAlignment = Alignment.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(color = Vojo.fleetSoft, strokeWidth = 2.dp, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(10.dp))
Text(label, color = Vojo.muted, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
private fun ThroughputCard(ratesFlow: StateFlow<VpnState.RateHistory>, status: VpnState.Status) {
PanelCard(Modifier.fillMaxWidth().height(116.dp)) {
Column(Modifier.fillMaxSize()) {
RateLegend(
ratesFlow = ratesFlow,
status = status,
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 2.dp),
)
ThroughputGraph(
ratesFlow = ratesFlow,
status = status,
modifier = Modifier.fillMaxWidth().weight(1f).padding(horizontal = 16.dp),
)
}
}
}
/** Low-frequency leaf: current ↓ / ↑ rate and the recent peak. Recomposes once per sample. */
@Composable
private fun StatsRow(stats: VpnState.Stats, connectedSince: Long) {
// Only this leaf subscribes to the 1 Hz tick, so the rest of the screen (and
// the pager) doesn't recompose every second while connected.
val nowMs = rememberNowTicker()
private fun RateLegend(
ratesFlow: StateFlow<VpnState.RateHistory>,
status: VpnState.Status,
modifier: Modifier = Modifier,
) {
val hist by ratesFlow.collectAsState()
val live = status == VpnState.Status.CONNECTED && hist.count > 0
val units = stringArrayResource(R.array.byte_units).asList()
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
StatTile(stringResource(R.string.stat_sent), formatBytes(stats.txBytes, units), Modifier.weight(1f))
StatTile(stringResource(R.string.stat_received), formatBytes(stats.rxBytes, units), Modifier.weight(1f))
StatTile(stringResource(R.string.stat_time), formatUptime(connectedSince, nowMs), Modifier.weight(1f))
val per = stringResource(R.string.rate_per_second)
val dash = ""
val rx = if (hist.count > 0) hist.rx[hist.count - 1].toLong() else 0L
val tx = if (hist.count > 0) hist.tx[hist.count - 1].toLong() else 0L
// "Peak" = the loudest sample currently on screen. Derived from the drawn window so it
// always matches the curve — unlike the internal decaying y-scale peak, which can decay
// to a stale value over a long idle stretch.
var peak = 0
for (i in 0 until hist.count) {
if (hist.rx[i] > peak) peak = hist.rx[i]
if (hist.tx[i] > peak) peak = hist.tx[i]
}
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
Text("", color = if (live) Vojo.fleet else Vojo.faint, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.width(6.dp))
Text(
if (live) formatRate(rx, units, per) else dash,
color = if (live) Vojo.text else Vojo.faint,
fontFamily = Vojo.mono, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, maxLines = 1,
)
Spacer(Modifier.width(14.dp))
Text("", color = Vojo.faint, fontSize = 11.sp)
Spacer(Modifier.width(5.dp))
Text(
if (live) formatRate(tx, units, per) else dash,
color = if (live) Vojo.muted else Vojo.faint,
fontFamily = Vojo.mono, fontSize = 12.sp, maxLines = 1,
)
Spacer(Modifier.weight(1f))
SectionLabel(stringResource(R.string.stat_peak))
Spacer(Modifier.width(6.dp))
Text(
if (live) formatRate(peak.toLong(), units, per) else dash,
color = if (live) Vojo.muted else Vojo.faint,
fontFamily = Vojo.mono, fontSize = 12.sp, maxLines = 1,
)
}
}
@SuppressLint("ProduceStateDoesNotAssignValue") // false positive: value is assigned inside repeatOnLifecycle
/**
* High-frequency leaf: the throughput sparkline. Download = a filled fleet area capped by a
* stroke; upload = a faint fleetSoft ghost line. Idle (down) shows a dashed baseline + em-dash.
* The per-sample glide and the y-scale ease are remembered here and only invalidate this
* leaf's draw never the screen or the pager.
*/
@Composable
private fun rememberNowTicker(): Long {
val lifecycle = LocalLifecycleOwner.current.lifecycle
val now by produceState(initialValue = System.currentTimeMillis(), lifecycle) {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
while (true) {
value = System.currentTimeMillis()
delay(1000)
private fun ThroughputGraph(
ratesFlow: StateFlow<VpnState.RateHistory>,
status: VpnState.Status,
modifier: Modifier = Modifier,
) {
val hist by ratesFlow.collectAsState()
val live = status == VpnState.Status.CONNECTED && hist.count > 0
// Decaying-peak y-scale: snap UP instantly so a spike never clips, ease DOWN over 250ms
// for the classic VU-meter settle.
val targetMax = maxOf(hist.peak, RATE_FLOOR).toFloat()
val yMax = remember { Animatable(RATE_FLOOR.toFloat()) }
LaunchedEffect(targetMax, live) {
val t = if (live) targetMax else RATE_FLOOR.toFloat()
if (t > yMax.value) yMax.snapTo(t) else yMax.animateTo(t, tween(250))
}
// One short ease per new sample — keyed on seq so it keeps firing after the ring
// saturates and count stops changing. The newest point glides from its previous value
// and the leading dot beats; between samples the Canvas is quiescent.
val appear = remember { Animatable(1f) }
LaunchedEffect(hist.seq, live) {
if (live && hist.seq > 0) {
appear.snapTo(0f)
appear.animateTo(1f, tween(550, easing = FastOutSlowInEasing))
} else {
appear.snapTo(1f)
}
}
val cap = VpnState.RATE_CAP
val sx = remember { FloatArray(cap) }
val syd = remember { FloatArray(cap) }
val syu = remember { FloatArray(cap) }
val dlPath = remember { Path() }
val fillPath = remember { Path() }
val upPath = remember { Path() }
Box(modifier) {
Canvas(Modifier.fillMaxSize()) {
val w = size.width
val h = size.height
val baselineY = h - 4.dp.toPx()
val topPad = 4.dp.toPx()
val usable = (baselineY - topPad).coerceAtLeast(1f)
if (!live) {
drawLine(
color = Vojo.rail,
start = Offset(0f, baselineY),
end = Offset(w, baselineY),
strokeWidth = 1.dp.toPx(),
pathEffect = PathEffect.dashPathEffect(floatArrayOf(3.dp.toPx(), 5.dp.toPx())),
)
return@Canvas
}
drawLine(Vojo.hairline, Offset(0f, baselineY), Offset(w, baselineY), 1.dp.toPx())
val n = hist.count.coerceAtMost(cap)
if (n < 1) return@Canvas
val maxY = yMax.value.coerceAtLeast(1f)
val minThick = 2.dp.toPx()
val dx = if (cap > 1) w / (cap - 1) else w
val last = n - 1
val ap = appear.value
fun yFor(rate: Int): Float {
if (rate <= 0) return baselineY
val hgt = ((rate / maxY).coerceIn(0f, 1f) * usable).coerceAtLeast(minThick)
return baselineY - hgt
}
for (i in 0 until n) sx[i] = w - (last - i) * dx
for (i in 0 until n) syd[i] = yFor(hist.rx[i])
for (i in 0 until n) syu[i] = yFor(hist.tx[i])
// Ease only the newest point in from its previous value.
if (n >= 2) {
syd[last] = lerp(yFor(hist.rx[last - 1]), syd[last], ap)
syu[last] = lerp(yFor(hist.tx[last - 1]), syu[last], ap)
}
// Upload ghost line first, so the download area sits on top.
if (n >= 2) {
upPath.reset()
buildSpline(upPath, sx, syu, n)
drawPath(
upPath, color = Vojo.fleetSoft.copy(alpha = 0.40f),
style = Stroke(width = 1.5.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round),
)
}
// Download: filled area + capped stroke.
if (n >= 2) {
dlPath.reset()
buildSpline(dlPath, sx, syd, n)
fillPath.reset()
fillPath.addPath(dlPath)
fillPath.lineTo(sx[last], baselineY)
fillPath.lineTo(sx[0], baselineY)
fillPath.close()
drawPath(
fillPath,
brush = Brush.verticalGradient(
colors = listOf(Vojo.fleet.copy(alpha = 0.22f), Vojo.fleet.copy(alpha = 0f)),
startY = topPad, endY = baselineY,
),
)
drawPath(
dlPath, color = Vojo.fleet,
style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round),
)
}
// Fade the oldest samples into the card background at the left edge.
val fade = 24.dp.toPx()
drawRect(
brush = Brush.horizontalGradient(listOf(Vojo.bgPanel, Color.Transparent), startX = 0f, endX = fade),
topLeft = Offset(0f, 0f),
size = Size(fade, h),
)
// Leading dot at the newest point, with a halo that beats on each sample.
val halo = lerp(6.dp.toPx(), 3.dp.toPx(), ap)
drawCircle(Vojo.fleet.copy(alpha = 0.25f), radius = halo, center = Offset(sx[last], syd[last]))
drawCircle(Vojo.fleet, radius = 3.dp.toPx(), center = Offset(sx[last], syd[last]))
}
if (!live) {
Text("", color = Vojo.faint, fontSize = 14.sp, modifier = Modifier.align(Alignment.Center))
}
}
}
/**
* Builds a smooth **monotone cubic** (FritschCarlson) curve through (xs, ys)[0..n) into
* [out] the top edge only. Monotone tangents cannot overshoot, so the area fill never
* dips below the baseline on a sharp drop to zero.
*/
private fun buildSpline(out: Path, xs: FloatArray, ys: FloatArray, n: Int) {
if (n <= 0) return
out.moveTo(xs[0], ys[0])
if (n == 1) return
if (n == 2) {
out.lineTo(xs[1], ys[1])
return
}
val d = FloatArray(n - 1) // secant slopes
val m = FloatArray(n) // point tangents
for (i in 0 until n - 1) {
val dxi = xs[i + 1] - xs[i]
d[i] = if (dxi != 0f) (ys[i + 1] - ys[i]) / dxi else 0f
}
m[0] = d[0]
m[n - 1] = d[n - 2]
for (i in 1 until n - 1) {
m[i] = if (d[i - 1] * d[i] <= 0f) 0f else (d[i - 1] + d[i]) / 2f
}
// FritschCarlson clamp so the interpolant stays monotone within each segment.
for (i in 0 until n - 1) {
if (d[i] == 0f) {
m[i] = 0f
m[i + 1] = 0f
} else {
val a = m[i] / d[i]
val b = m[i + 1] / d[i]
val s = a * a + b * b
if (s > 9f) {
val t = 3f / sqrt(s)
m[i] = t * a * d[i]
m[i + 1] = t * b * d[i]
}
}
}
return now
}
@Composable
private fun StatTile(label: String, value: String, modifier: Modifier = Modifier) {
PanelCard(modifier) {
Column(Modifier.padding(14.dp)) {
Text(label, color = Vojo.faint, fontSize = 12.sp)
Spacer(Modifier.height(5.dp))
Text(value, color = Vojo.text, fontFamily = Vojo.mono, fontSize = 17.sp, fontWeight = FontWeight.SemiBold, maxLines = 1)
}
for (i in 0 until n - 1) {
val hseg = xs[i + 1] - xs[i]
out.cubicTo(
xs[i] + hseg / 3f, ys[i] + m[i] * hseg / 3f,
xs[i + 1] - hseg / 3f, ys[i + 1] - m[i + 1] * hseg / 3f,
xs[i + 1], ys[i + 1],
)
}
}

View file

@ -118,6 +118,16 @@ object VojoIcons {
}
}
/** Filled play triangle — the "connect / start the tunnel" affordance (pairs with [Cross]). */
val Play: ImageVector by lazy {
fill("play") {
moveTo(8f, 6.5f)
lineTo(8f, 17.5f)
lineTo(17.5f, 12f)
close()
}
}
val Clipboard: ImageVector by lazy {
stroke("clipboard") {
moveTo(9f, 5f)

View file

@ -45,9 +45,7 @@ fun MainScreen(
) {
val proxyState by vm.state.collectAsState()
val status by vm.status.collectAsState()
val stats by vm.stats.collectAsState()
val error by vm.error.collectAsState()
val connectedSince by vm.connectedSince.collectAsState()
val events by vm.events.collectAsState()
val applied by vm.applied.collectAsState()
val apps by vm.apps.collectAsState()
@ -130,8 +128,7 @@ fun MainScreen(
activeConfig = active,
configs = proxyState.configs,
status = status,
stats = stats,
connectedSince = connectedSince,
ratesFlow = vm.rates,
error = error,
events = events,
applied = applied,

View file

@ -39,6 +39,8 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val status: StateFlow<VpnState.Status> = VpnState.status
val stats: StateFlow<VpnState.Stats> = VpnState.stats
/** Raw throughput-history flow — collected only inside the graph leaves, never higher. */
val rates: StateFlow<VpnState.RateHistory> = VpnState.rates
val error: StateFlow<String?> = VpnState.error
val connectedSince: StateFlow<Long> = VpnState.connectedSince
val events: StateFlow<List<VpnState.TunnelEvent>> = VpnState.events