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])
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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** (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]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue