feat(ui): move connect/disconnect into the server card; replace sent/received/time tiles with a live throughput graph
This commit is contained in:
parent
15cdd397cb
commit
5cfb893d6e
5 changed files with 361 additions and 106 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
if (active) {
|
||||
// Disconnect lives inside the card as a square ✕.
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Box(
|
||||
Modifier
|
||||
}
|
||||
}
|
||||
|
||||
/** 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(Vojo.danger.copy(alpha = 0.14f))
|
||||
.clickable { onDisconnect() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
.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))
|
||||
}
|
||||
} 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)
|
||||
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))
|
||||
}
|
||||
}
|
||||
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)
|
||||
/**
|
||||
* Builds a smooth **monotone cubic** (Fritsch–Carlson) 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
|
||||
}
|
||||
// Fritsch–Carlson 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]
|
||||
}
|
||||
}
|
||||
}
|
||||
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],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue