From 5cfb893d6e85fd2df60920959f431d2004611efb Mon Sep 17 00:00:00 2001 From: heaven Date: Tue, 16 Jun 2026 21:39:53 +0300 Subject: [PATCH] feat(ui): move connect/disconnect into the server card; replace sent/received/time tiles with a live throughput graph --- .../main/java/chat/vojo/proxy/core/Format.kt | 7 + .../chat/vojo/proxy/ui/ConnectionScreen.kt | 443 ++++++++++++++---- app/src/main/java/chat/vojo/proxy/ui/Icons.kt | 10 + .../java/chat/vojo/proxy/ui/MainScreen.kt | 5 +- .../java/chat/vojo/proxy/ui/MainViewModel.kt | 2 + 5 files changed, 361 insertions(+), 106 deletions(-) diff --git a/app/src/main/java/chat/vojo/proxy/core/Format.kt b/app/src/main/java/chat/vojo/proxy/core/Format.kt index 1376500..ea097a8 100644 --- a/app/src/main/java/chat/vojo/proxy/core/Format.kt +++ b/app/src/main/java/chat/vojo/proxy/core/Format.kt @@ -16,6 +16,13 @@ fun formatBytes(b: Long, units: List): 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, 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) diff --git a/app/src/main/java/chat/vojo/proxy/ui/ConnectionScreen.kt b/app/src/main/java/chat/vojo/proxy/ui/ConnectionScreen.kt index 0c0d0a5..f07b7de 100644 --- a/app/src/main/java/chat/vojo/proxy/ui/ConnectionScreen.kt +++ b/app/src/main/java/chat/vojo/proxy/ui/ConnectionScreen.kt @@ -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, status: VpnState.Status, - stats: VpnState.Stats, - connectedSince: Long, + ratesFlow: StateFlow, error: String?, events: List, 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, 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, + 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, + 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** (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 -} - -@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], + ) } } diff --git a/app/src/main/java/chat/vojo/proxy/ui/Icons.kt b/app/src/main/java/chat/vojo/proxy/ui/Icons.kt index 0e266f0..41abca8 100644 --- a/app/src/main/java/chat/vojo/proxy/ui/Icons.kt +++ b/app/src/main/java/chat/vojo/proxy/ui/Icons.kt @@ -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) diff --git a/app/src/main/java/chat/vojo/proxy/ui/MainScreen.kt b/app/src/main/java/chat/vojo/proxy/ui/MainScreen.kt index 3ae9296..457d421 100644 --- a/app/src/main/java/chat/vojo/proxy/ui/MainScreen.kt +++ b/app/src/main/java/chat/vojo/proxy/ui/MainScreen.kt @@ -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, diff --git a/app/src/main/java/chat/vojo/proxy/ui/MainViewModel.kt b/app/src/main/java/chat/vojo/proxy/ui/MainViewModel.kt index e3b461d..9ee3ce1 100644 --- a/app/src/main/java/chat/vojo/proxy/ui/MainViewModel.kt +++ b/app/src/main/java/chat/vojo/proxy/ui/MainViewModel.kt @@ -39,6 +39,8 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { val status: StateFlow = VpnState.status val stats: StateFlow = VpnState.stats + /** Raw throughput-history flow — collected only inside the graph leaves, never higher. */ + val rates: StateFlow = VpnState.rates val error: StateFlow = VpnState.error val connectedSince: StateFlow = VpnState.connectedSince val events: StateFlow> = VpnState.events