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]) 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 { fun formatUptime(since: Long, nowMs: Long): String {
if (since <= 0) return "" if (since <= 0) return ""
val s = ((nowMs - since) / 1000).coerceAtLeast(0) val s = ((nowMs - since) / 1000).coerceAtLeast(0)

View file

@ -1,15 +1,18 @@
package chat.vojo.proxy.ui 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.LinearEasing
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -33,17 +36,24 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.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.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle import androidx.compose.ui.util.lerp
import androidx.lifecycle.repeatOnLifecycle
import chat.vojo.proxy.R import chat.vojo.proxy.R
import chat.vojo.proxy.core.AppSettings import chat.vojo.proxy.core.AppSettings
import chat.vojo.proxy.core.ProxyConfig import chat.vojo.proxy.core.ProxyConfig
import chat.vojo.proxy.core.ProxyType import chat.vojo.proxy.core.ProxyType
import chat.vojo.proxy.core.formatBytes
import chat.vojo.proxy.core.formatClock 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.service.VpnState
import chat.vojo.proxy.ui.theme.Vojo import chat.vojo.proxy.ui.theme.Vojo
import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow
import java.util.Calendar import java.util.Calendar
import kotlin.math.sqrt
private fun protoColor(config: ProxyConfig?): Color = when (config?.type) { private fun protoColor(config: ProxyConfig?): Color = when (config?.type) {
ProxyType.SHADOWSOCKS -> Vojo.fleet ProxyType.SHADOWSOCKS -> Vojo.fleet
@ -94,8 +103,7 @@ fun ConnectionScreen(
activeConfig: ProxyConfig?, activeConfig: ProxyConfig?,
configs: List<ProxyConfig>, configs: List<ProxyConfig>,
status: VpnState.Status, status: VpnState.Status,
stats: VpnState.Stats, ratesFlow: StateFlow<VpnState.RateHistory>,
connectedSince: Long,
error: String?, error: String?,
events: List<VpnState.TunnelEvent>, events: List<VpnState.TunnelEvent>,
applied: VpnState.Applied?, applied: VpnState.Applied?,
@ -121,9 +129,10 @@ fun ConnectionScreen(
ActiveServerCard( ActiveServerCard(
config = activeConfig, config = activeConfig,
status = status,
live = live, live = live,
active = active,
onPick = if (configs.size > 1 && !active) ({ pickerOpen = true }) else null, onPick = if (configs.size > 1 && !active) ({ pickerOpen = true }) else null,
onConnect = onConnect,
onDisconnect = onDisconnect, onDisconnect = onDisconnect,
) )
@ -145,16 +154,10 @@ fun ConnectionScreen(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// Fixed-height slot: button when down, stats when up — swapping in // The throughput graph replaces the old connect-button / stats slot. It keeps a
// place means the journal below never jumps on connect/disconnect. // fixed height in every state (idle frame when down, live curve when up) so the
Box(Modifier.fillMaxWidth().height(72.dp), contentAlignment = Alignment.Center) { // journal below never jumps on connect/disconnect.
when { ThroughputCard(ratesFlow = ratesFlow, status = status)
connected -> StatsRow(stats, connectedSince)
status == VpnState.Status.CONNECTING || status == VpnState.Status.STOPPING ->
ConnectingButton(status)
else -> ConnectButton(status, onConnect, enabled = activeConfig != null)
}
}
} }
// ── Scrollable journal — the only thing that scrolls; newest on top ── // ── Scrollable journal — the only thing that scrolls; newest on top ──
@ -197,9 +200,10 @@ private fun ErrorBanner(message: String) {
@Composable @Composable
private fun ActiveServerCard( private fun ActiveServerCard(
config: ProxyConfig?, config: ProxyConfig?,
status: VpnState.Status,
live: Boolean, live: Boolean,
active: Boolean,
onPick: (() -> Unit)?, onPick: (() -> Unit)?,
onConnect: () -> Unit,
onDisconnect: () -> Unit, onDisconnect: () -> Unit,
) { ) {
PanelCard( PanelCard(
@ -225,28 +229,74 @@ private fun ActiveServerCard(
} }
} }
Spacer(Modifier.height(3.dp)) Spacer(Modifier.height(3.dp))
Text( Row(verticalAlignment = Alignment.CenterVertically) {
config?.handle ?: stringResource(R.string.conn_choose_server), Text(
color = Vojo.muted, fontFamily = Vojo.mono, fontSize = 13.sp, config?.handle ?: stringResource(R.string.conn_choose_server),
maxLines = 1, overflow = TextOverflow.Ellipsis, color = Vojo.muted, fontFamily = Vojo.mono, fontSize = 13.sp,
) maxLines = 1, overflow = TextOverflow.Ellipsis,
} modifier = Modifier.weight(1f, fill = false),
if (active) { )
// Disconnect lives inside the card as a square ✕. // Subtle "tap the card to change server" hint — only when a picker exists.
Spacer(Modifier.width(10.dp)) if (onPick != null) {
Box( Spacer(Modifier.width(5.dp))
Modifier Icon(VojoIcons.ChevronDown, contentDescription = null, tint = Vojo.faint, modifier = Modifier.size(14.dp))
.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))
} }
} else if (config != null && onPick != null) { }
Spacer(Modifier.width(8.dp)) // The connect/disconnect action now lives inside the card, in the same
Text(stringResource(R.string.conn_change_server_short), color = Vojo.fleetSoft, fontSize = 13.sp) // 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 /** Floor for the graph's y-scale (64 KiB/s) so idle/low traffic never looks huge. */
private fun ConnectButton(status: VpnState.Status, onConnect: () -> Unit, enabled: Boolean) { private const val RATE_FLOOR = 65536
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)
}
}
/** 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 @Composable
private fun ConnectingButton(status: VpnState.Status) { private fun ThroughputCard(ratesFlow: StateFlow<VpnState.RateHistory>, status: VpnState.Status) {
val label = if (status == VpnState.Status.STOPPING) stringResource(R.string.conn_disconnecting) else stringResource(R.string.notif_connecting) PanelCard(Modifier.fillMaxWidth().height(116.dp)) {
Box( Column(Modifier.fillMaxSize()) {
Modifier RateLegend(
.fillMaxWidth() ratesFlow = ratesFlow,
.height(58.dp) status = status,
.clip(RoundedCornerShape(15.dp)) modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 2.dp),
.background(Vojo.surface), )
contentAlignment = Alignment.Center, ThroughputGraph(
) { ratesFlow = ratesFlow,
Row(verticalAlignment = Alignment.CenterVertically) { status = status,
CircularProgressIndicator(color = Vojo.fleetSoft, strokeWidth = 2.dp, modifier = Modifier.size(16.dp)) modifier = Modifier.fillMaxWidth().weight(1f).padding(horizontal = 16.dp),
Spacer(Modifier.width(10.dp)) )
Text(label, color = Vojo.muted, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
} }
} }
} }
/** Low-frequency leaf: current ↓ / ↑ rate and the recent peak. Recomposes once per sample. */
@Composable @Composable
private fun StatsRow(stats: VpnState.Stats, connectedSince: Long) { private fun RateLegend(
// Only this leaf subscribes to the 1 Hz tick, so the rest of the screen (and ratesFlow: StateFlow<VpnState.RateHistory>,
// the pager) doesn't recompose every second while connected. status: VpnState.Status,
val nowMs = rememberNowTicker() 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() val units = stringArrayResource(R.array.byte_units).asList()
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { val per = stringResource(R.string.rate_per_second)
StatTile(stringResource(R.string.stat_sent), formatBytes(stats.txBytes, units), Modifier.weight(1f)) val dash = ""
StatTile(stringResource(R.string.stat_received), formatBytes(stats.rxBytes, units), Modifier.weight(1f)) val rx = if (hist.count > 0) hist.rx[hist.count - 1].toLong() else 0L
StatTile(stringResource(R.string.stat_time), formatUptime(connectedSince, nowMs), Modifier.weight(1f)) 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 @Composable
private fun rememberNowTicker(): Long { private fun ThroughputGraph(
val lifecycle = LocalLifecycleOwner.current.lifecycle ratesFlow: StateFlow<VpnState.RateHistory>,
val now by produceState(initialValue = System.currentTimeMillis(), lifecycle) { status: VpnState.Status,
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { modifier: Modifier = Modifier,
while (true) { ) {
value = System.currentTimeMillis() val hist by ratesFlow.collectAsState()
delay(1000) 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 for (i in 0 until n - 1) {
} val hseg = xs[i + 1] - xs[i]
out.cubicTo(
@Composable xs[i] + hseg / 3f, ys[i] + m[i] * hseg / 3f,
private fun StatTile(label: String, value: String, modifier: Modifier = Modifier) { xs[i + 1] - hseg / 3f, ys[i + 1] - m[i + 1] * hseg / 3f,
PanelCard(modifier) { xs[i + 1], ys[i + 1],
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)
}
} }
} }

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 { val Clipboard: ImageVector by lazy {
stroke("clipboard") { stroke("clipboard") {
moveTo(9f, 5f) moveTo(9f, 5f)

View file

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

View file

@ -39,6 +39,8 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val status: StateFlow<VpnState.Status> = VpnState.status val status: StateFlow<VpnState.Status> = VpnState.status
val stats: StateFlow<VpnState.Stats> = VpnState.stats 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 error: StateFlow<String?> = VpnState.error
val connectedSince: StateFlow<Long> = VpnState.connectedSince val connectedSince: StateFlow<Long> = VpnState.connectedSince
val events: StateFlow<List<VpnState.TunnelEvent>> = VpnState.events val events: StateFlow<List<VpnState.TunnelEvent>> = VpnState.events