android: remove the pre-mockup Home parts (hero, banners, availability card, traffic tiles, glow)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-12 14:47:33 +07:00
1 parent 15ad5c1151
commit 92627da459
7 files changed
+43 -1298

No files matched your search

@@ -150,9 +150,6 @@ import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntSize
import org.strongswan.android.ui.compose.home.AvailabilityCard
import org.strongswan.android.ui.compose.home.HeroConnect
import org.strongswan.android.ui.compose.home.HomeBannerSlot
import org.strongswan.android.ui.compose.home.DEFAULT_PAUSE_MINUTES
import org.strongswan.android.ui.compose.home.HomeProblem
import org.strongswan.android.ui.compose.home.PauseSheet
@@ -167,9 +164,7 @@ import org.strongswan.android.logic.events.EventLog
import android.content.Intent
import android.os.Build
import android.provider.Settings
import org.strongswan.android.ui.compose.home.ProfileChip
import org.strongswan.android.ui.compose.home.RTT_HISTORY
import org.strongswan.android.ui.compose.home.TrafficTiles
import org.strongswan.android.ui.compose.home.labelRes
import org.strongswan.android.ui.compose.home.rttText
import org.strongswan.android.ui.compose.home.sortedForDisplay
@@ -520,58 +515,6 @@ private fun ProfilePickerSheet(
}
}
/** Soft state-colored glow behind the hero; the pulse is skipped when system animations are off. */
@Composable
private fun StatusGlowBackground(color: Color, active: Boolean)
{
val context = LocalContext.current
val animationsOn = remember {
android.provider.Settings.Global.getFloat(
context.contentResolver,
android.provider.Settings.Global.ANIMATOR_DURATION_SCALE,
1f,
) != 0f
}
val animated by animateColorAsState(color, label = "glow")
if (!active)
{ /* nothing to draw: no canvas and, above all, no infinite transition
* redrawing a transparent circle every frame while disconnected */
return
}
if (animationsOn)
{
val transition = rememberInfiniteTransition(label = "pulse")
/* read inside the draw scope below so the pulse only invalidates drawing,
* instead of recomposing this composable on every frame */
val pulse by transition.animateFloat(
initialValue = 0.30f,
targetValue = 0.55f,
animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing), RepeatMode.Reverse),
label = "a",
)
Canvas(Modifier.fillMaxSize()) { drawGlow(animated, pulse) }
}
else
{
Canvas(Modifier.fillMaxSize()) { drawGlow(animated, 0.42f) }
}
}
private fun DrawScope.drawGlow(color: Color, alpha: Float)
{
val center = Offset(size.width / 2f, size.height * 0.16f)
val radius = size.minDimension * 0.62f
drawCircle(
brush = Brush.radialGradient(
colors = listOf(color.copy(alpha = alpha * 0.30f), Color.Transparent),
center = center,
radius = radius,
),
radius = radius,
center = center,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ConnectionDetailsSheet(
@@ -1,80 +0,0 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.strongswan.android.ui.compose.theme.ShxSpacing
/**
* Generic list row: optional leading slot, headline + supporting text, optional trailing slot.
* Minimum height 56dp keeps touch targets above 48dp. Clickable when [onClick] is set.
*/
@Composable
fun ShxListRow(
headline: String,
modifier: Modifier = Modifier,
supporting: String? = null,
enabled: Boolean = true,
leading: (@Composable () -> Unit)? = null,
trailing: (@Composable () -> Unit)? = null,
onClick: (() -> Unit)? = null,
)
{
val clickable = if (onClick != null) Modifier.clickable(enabled = enabled, onClick = onClick) else Modifier
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.then(clickable)
.heightIn(min = 56.dp)
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
)
{
if (leading != null)
{
leading()
}
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = if (leading != null || trailing != null) ShxSpacing.lg else 0.dp),
)
{
Text(
headline,
style = MaterialTheme.typography.bodyLarge,
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
if (supporting != null)
{
Text(
supporting,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (trailing != null)
{
trailing()
}
}
}
@@ -1,575 +0,0 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
package org.strongswan.android.ui.compose.home
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.AltRoute
import androidx.compose.material.icons.rounded.ArrowDownward
import androidx.compose.material.icons.rounded.ArrowUpward
import androidx.compose.material.icons.rounded.ChevronRight
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Dns
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.GppMaybe
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Public
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material.icons.rounded.SignalCellularAlt
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
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.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.StateFlow
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.logic.VpnStateService.ErrorState
import org.strongswan.android.logic.VpnStateService.State
import org.strongswan.android.logic.connectivity.ProbeResult
import org.strongswan.android.logic.connectivity.ProbeTarget
import org.strongswan.android.logic.connectivity.WhiteListState
import org.strongswan.android.logic.connectivity.WhiteListStatus
import org.strongswan.android.ui.compose.components.ShxCard
import org.strongswan.android.ui.compose.components.ShxEmptyState
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ScreenPadding
import org.strongswan.android.ui.compose.DeadTargetUi
import org.strongswan.android.ui.compose.ExitUi
import org.strongswan.android.ui.compose.ShxDivider
import org.strongswan.android.ui.compose.ProfileAvatar
import org.strongswan.android.ui.compose.TrafficUi
import org.strongswan.android.ui.compose.formatBytes
import org.strongswan.android.ui.compose.formatDuration
import org.strongswan.android.ui.compose.formatRate
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.runtime.saveable.rememberSaveable
import org.strongswan.android.ui.compose.theme.ShxSpacing
/* ---------------- availability card ---------------- */
internal val PROBE_ORDER = listOf(ProbeTarget.SERVER, ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE)
/** Server first, then external targets in fixed order. */
internal fun Map<ProbeTarget, ProbeResult>.sortedForDisplay(): List<ProbeResult> =
PROBE_ORDER.filter { containsKey(it) }.mapNotNull { this[it] }
internal fun ProbeTarget.labelRes(): Int = when (this)
{
ProbeTarget.SERVER -> R.string.probe_target_server
ProbeTarget.GOOGLE -> R.string.probe_target_google
ProbeTarget.YOUTUBE -> R.string.probe_target_youtube
ProbeTarget.CLOUDFLARE -> R.string.probe_target_cloudflare
}
@Composable
internal fun ProbeResult.rttText(): String = when
{
ok && rttMs != null -> stringResource(R.string.probe_rtt_fmt, rttMs)
else -> stringResource(R.string.value_no_data)
}
/** A dead direct destination: address, the app hammering it, refusal stats,
* and a one-tap action to force the host through the tunnel. */
@Composable
internal fun DeadTargetRow(target: DeadTargetUi, onForceTunnel: (String) -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.padding(start = 18.dp, top = 6.dp, bottom = 2.dp),
) {
Column(Modifier.weight(1f)) {
Text(
text = target.dst,
style = MaterialTheme.typography.bodyMedium,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
)
Text(
text = listOfNotNull(
target.owner,
stringResource(R.string.dead_targets_fails_fmt, target.fails),
stringResource(R.string.dead_targets_cooldown_fmt, formatDuration(target.leftMs / 1000)),
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (target.viaTunnel)
{
Text(
text = stringResource(R.string.dead_targets_via_tunnel),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
else
{
TextButton(
onClick = { onForceTunnel(target.dst) },
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
) {
Text(
text = stringResource(R.string.dead_targets_force),
style = MaterialTheme.typography.labelMedium,
)
}
}
}
}
@Composable
internal fun DirectProbeRow(label: String, ok: Boolean?, rttMs: Long?)
{ Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
)
{
val status = LocalShxStatusColors.current
/* tri-state: null = not checked yet, false = checked and unreachable */
val dotColor = when (ok)
{
true -> status.connected
false -> status.disconnected
null -> MaterialTheme.colorScheme.outlineVariant
}
Box(
modifier = Modifier
.size(8.dp)
.background(dotColor, CircleShape),
)
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
)
Text(
text = when
{
ok == true && rttMs != null -> stringResource(R.string.probe_rtt_fmt, rttMs)
ok == false -> stringResource(R.string.value_unreachable)
else -> stringResource(R.string.value_no_data)
},
style = MaterialTheme.typography.bodyMedium,
color = when (ok)
{
true -> MaterialTheme.colorScheme.onSurface
false -> status.disconnected
null -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
@Composable
internal fun ProbeRow(result: ProbeResult, measuring: Boolean, history: List<ProbeResult?> = emptyList())
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
)
{
val status = LocalShxStatusColors.current
val dotColor = when
{
!result.ok -> status.disconnected
result.rttMs != null && result.rttMs < WARN_RTT_MS -> status.connected
result.rttMs != null && result.rttMs < FAIL_RTT_MS -> status.connecting
else -> status.disconnected
}
Box(
modifier = Modifier
.size(8.dp)
.background(dotColor, CircleShape),
)
Text(
text = stringResource(result.target.labelRes()),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
)
RttSparkline(samples = history)
Spacer(Modifier.size(8.dp))
if (result.ok && result.rttMs == null && measuring)
{
LoadingIndicator(Modifier.size(12.dp))
}
else
{
Text(
text = result.rttText(),
style = MaterialTheme.typography.bodyMedium,
color = if (result.ok) MaterialTheme.colorScheme.onSurface else status.disconnected,
)
}
}
}
internal const val RTT_HISTORY = 16
/** Tiny bar chart of the last RTT samples; missing samples render as dim ticks. */
@Composable
internal fun RttSparkline(samples: List<ProbeResult?>)
{
val status = LocalShxStatusColors.current
val idle = MaterialTheme.colorScheme.outlineVariant
Canvas(modifier = Modifier.size(width = 52.dp, height = 16.dp))
{
val barW = size.width / RTT_HISTORY
val barActual = barW * 0.6f
val shown = samples.takeLast(RTT_HISTORY)
shown.forEachIndexed { index, sample ->
val x = size.width - (shown.size - index) * barW
val rtt = sample?.rttMs
if (sample == null || rtt == null)
{
drawRoundRect(
color = idle.copy(alpha = 0.5f),
topLeft = Offset(x + (barW - barActual) / 2, size.height - 2.dp.toPx()),
size = Size(barActual, 2.dp.toPx()),
cornerRadius = CornerRadius(1.dp.toPx()),
)
}
else
{
val fraction = (rtt.toFloat() / FAIL_RTT_MS).coerceIn(0.12f, 1f)
val barH = size.height * fraction
val color = when
{
!sample.ok -> status.disconnected
rtt < WARN_RTT_MS -> status.connected
else -> status.connecting
}
drawRoundRect(
color = color,
topLeft = Offset(x + (barW - barActual) / 2, size.height - barH),
size = Size(barActual, barH),
cornerRadius = CornerRadius(2.dp.toPx()),
)
}
}
}
}
internal const val WARN_RTT_MS = 150L
internal const val FAIL_RTT_MS = 400L
/**
* Availability probes. Collapsed by default into one summary row (colored dots + worst
* RTT); expands in place with the per-target rows, dead-host list and white-list probes.
*/
@Composable
internal fun AvailabilityCard(
modifier: Modifier = Modifier,
probes: Map<ProbeTarget, ProbeResult>,
probing: Boolean,
whiteList: WhiteListState,
history: Map<ProbeTarget, List<ProbeResult?>>,
deadTargets: List<DeadTargetUi>,
onOpenRouting: () -> Unit,
onForceTunnel: (String) -> Unit,
onRefresh: () -> Unit,
)
{
var expanded by rememberSaveable { mutableStateOf(false) }
val rows = probes.sortedForDisplay()
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg)
.animateContentSize(MaterialTheme.motionScheme.defaultSpatialSpec()),
)
{
PullToRefreshBox(isRefreshing = probing, onRefresh = onRefresh)
{
Column(Modifier.padding(ShxSpacing.lg))
{
val toggleLabel = stringResource(if (expanded) R.string.availability_collapse else R.string.availability_expand)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClickLabel = toggleLabel) { expanded = !expanded },
)
{
Text(
text = stringResource(R.string.availability_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
if (probing)
{
LoadingIndicator(Modifier.size(24.dp))
Spacer(Modifier.size(ShxSpacing.sm))
}
if (!expanded)
{
AvailabilitySummary(rows = rows, deadCount = deadTargets.size)
}
else
{
IconButton(onClick = onRefresh, enabled = !probing) {
Icon(
Icons.Rounded.Refresh,
contentDescription = stringResource(R.string.availability_refresh),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Icon(
if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
AnimatedVisibility(visible = expanded)
{
Column {
Spacer(Modifier.height(ShxSpacing.xs))
if (rows.isEmpty())
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(top = ShxSpacing.xs),
)
{
LoadingIndicator(Modifier.size(24.dp))
Text(
text = stringResource(R.string.availability_measuring),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
else
{
rows.forEachIndexed { index, result ->
ProbeRow(result = result, measuring = probing, history = history[result.target].orEmpty())
if (index < rows.lastIndex)
{
ShxDivider()
}
}
}
if (deadTargets.isNotEmpty())
{
DeadTargetsSection(deadTargets, onOpenRouting, onForceTunnel)
}
if (whiteList.config.enabled)
{
WhiteListProbes(whiteList)
}
}
}
}
}
}
}
@Composable
private fun AvailabilitySummary(rows: List<ProbeResult>, deadCount: Int)
{
val status = LocalShxStatusColors.current
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(ShxSpacing.xs))
{
rows.forEach { result ->
val dot = when
{
!result.ok -> status.disconnected
result.rttMs != null && result.rttMs < WARN_RTT_MS -> status.connected
result.rttMs != null && result.rttMs < FAIL_RTT_MS -> status.connecting
else -> status.disconnected
}
Box(Modifier.size(8.dp).background(dot, CircleShape))
}
if (deadCount > 0)
{
Box(Modifier.size(8.dp).background(MaterialTheme.colorScheme.error, CircleShape))
}
val worst = rows.filter { it.ok }.mapNotNull { it.rttMs }.maxOrNull()
val anyFailed = rows.any { !it.ok }
Text(
text = when
{
anyFailed -> stringResource(R.string.value_unreachable)
worst != null -> stringResource(R.string.probe_rtt_fmt, worst)
else -> stringResource(R.string.value_no_data)
},
style = MaterialTheme.typography.bodyMedium,
color = if (anyFailed) status.disconnected else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = ShxSpacing.xs),
)
}
}
@Composable
private fun DeadTargetsSection(deadTargets: List<DeadTargetUi>, onOpenRouting: () -> Unit, onForceTunnel: (String) -> Unit)
{
Spacer(Modifier.height(ShxSpacing.sm))
ShxDivider()
Spacer(Modifier.height(ShxSpacing.sm))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpenRouting)
.padding(vertical = ShxSpacing.xs),
) {
Box(Modifier.size(8.dp).background(MaterialTheme.colorScheme.error, CircleShape))
Text(
text = stringResource(R.string.dead_targets_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Icon(
Icons.Rounded.ChevronRight,
contentDescription = stringResource(R.string.dead_targets_open),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
deadTargets.take(3).forEach { target ->
DeadTargetRow(target = target, onForceTunnel = onForceTunnel)
}
if (deadTargets.size > 3)
{
Text(
text = stringResource(R.string.dead_targets_more_fmt, deadTargets.size - 3),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 18.dp, top = ShxSpacing.xs),
)
}
}
@Composable
private fun WhiteListProbes(whiteList: WhiteListState)
{
Spacer(Modifier.height(ShxSpacing.sm))
ShxDivider()
Spacer(Modifier.height(ShxSpacing.sm))
Text(
text = stringResource(R.string.whitelist_direct_section),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
DirectProbeRow(
label = stringResource(R.string.whitelist_foreign_direct),
ok = whiteList.foreignDirectOk,
rttMs = whiteList.foreignDirectRttMs,
)
ShxDivider()
DirectProbeRow(
label = stringResource(R.string.whitelist_domestic_direct),
ok = whiteList.domesticDirectOk,
rttMs = whiteList.domesticDirectRttMs,
)
}
@@ -1,244 +0,0 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui.compose.home
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.Box
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.PowerSettingsNew
import androidx.compose.material.icons.rounded.UnfoldMore
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.asComposePath
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.graphics.shapes.Morph
import androidx.graphics.shapes.toPath
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.logic.VpnStateService.State
import org.strongswan.android.ui.compose.ExitUi
import org.strongswan.android.ui.compose.ProfileAvatar
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
/**
* The main connect control: a large expressive shape that morphs between a circle
* (disconnected), a scalloped cookie (connecting) and the full cookie (connected),
* with the state color and a loading indicator while the tunnel is coming up.
*/
@Composable
internal fun HeroConnect(
state: State,
enabled: Boolean,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
)
{
val status = LocalShxStatusColors.current
val connected = state == State.CONNECTED
val connecting = state == State.CONNECTING || state == State.DISCONNECTING
val haptics = LocalHapticFeedback.current
val container by animateColorAsState(
targetValue = when
{
connected -> status.connectedContainer
connecting -> status.connectingContainer
else -> MaterialTheme.colorScheme.primary
},
animationSpec = MaterialTheme.motionScheme.defaultEffectsSpec(),
label = "heroContainer",
)
val content by animateColorAsState(
targetValue = when
{
connected -> status.connected
connecting -> status.connecting
else -> MaterialTheme.colorScheme.onPrimary
},
animationSpec = MaterialTheme.motionScheme.defaultEffectsSpec(),
label = "heroContent",
)
val progress by animateFloatAsState(
targetValue = when
{
connected -> 1f
connecting -> 0.5f
else -> 0f
},
animationSpec = MaterialTheme.motionScheme.slowSpatialSpec(),
label = "heroMorph",
)
val morph = remember { Morph(MaterialShapes.Circle, MaterialShapes.Cookie9Sided) }
val shape = remember(progress) { MorphShape(morph, progress) }
val label = stringResource(
when
{
connected -> R.string.disconnect
connecting -> android.R.string.cancel
else -> R.string.connect
}
)
Surface(
onClick = {
when
{
connected ->
{
haptics.performHapticFeedback(HapticFeedbackType.Reject)
onDisconnect()
}
connecting -> onCancel()
else ->
{
haptics.performHapticFeedback(HapticFeedbackType.Confirm)
onConnect()
}
}
},
enabled = enabled || connected || connecting,
shape = shape,
color = container,
contentColor = content,
modifier = modifier
.size(176.dp)
.semantics {
contentDescription = label
role = Role.Button
},
)
{
Box(contentAlignment = Alignment.Center)
{
if (connecting)
{
LoadingIndicator(Modifier.size(72.dp), color = content)
}
else
{
Icon(
Icons.Rounded.PowerSettingsNew,
contentDescription = null,
modifier = Modifier.size(72.dp),
)
}
}
}
}
/** Current profile as a chip: name, and exit country + IP while connected; tap opens the picker. */
@Composable
internal fun ProfileChip(
profile: VpnProfile?,
exit: ExitUi?,
connected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
)
{
val secondary = when
{
connected && exit?.ip != null -> exit.country?.let { "$it · ${exit.ip}" } ?: exit.ip
else -> profile?.gateway ?: ""
}
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = modifier.fillMaxWidth(),
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
)
{
ProfileAvatar(icon = Icons.Rounded.Language, connected = connected)
Column(Modifier.weight(1f))
{
Text(
text = profile?.name ?: stringResource(R.string.no_profiles),
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (!secondary.isNullOrEmpty())
{
Text(
text = secondary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Icon(
Icons.Rounded.UnfoldMore,
contentDescription = stringResource(R.string.home_profile_picker_title),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/** Compose Shape drawing a [Morph] at [progress], scaled to fill the layout bounds. */
private class MorphShape(private val morph: Morph, private val progress: Float) : Shape
{
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline
{
val bounds = morph.calculateBounds()
val width = bounds[2] - bounds[0]
val height = bounds[3] - bounds[1]
val matrix = android.graphics.Matrix()
matrix.setScale(size.width / width, size.height / height)
matrix.preTranslate(-bounds[0], -bounds[1])
val path = morph.toPath(progress)
path.transform(matrix)
return Outline.Generic(path.asComposePath())
}
}
@@ -1,234 +0,0 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
package org.strongswan.android.ui.compose.home
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.AltRoute
import androidx.compose.material.icons.rounded.ArrowDownward
import androidx.compose.material.icons.rounded.ArrowUpward
import androidx.compose.material.icons.rounded.ChevronRight
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Dns
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.GppMaybe
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Public
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material.icons.rounded.SignalCellularAlt
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
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.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.StateFlow
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.logic.VpnStateService.ErrorState
import org.strongswan.android.logic.VpnStateService.State
import org.strongswan.android.logic.connectivity.ProbeResult
import org.strongswan.android.logic.connectivity.ProbeTarget
import org.strongswan.android.logic.connectivity.WhiteListState
import org.strongswan.android.logic.connectivity.WhiteListStatus
import org.strongswan.android.ui.compose.components.ShxCard
import org.strongswan.android.ui.compose.components.ShxEmptyState
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ScreenPadding
import org.strongswan.android.ui.compose.DeadTargetUi
import org.strongswan.android.ui.compose.ExitUi
import org.strongswan.android.ui.compose.ShxDivider
import org.strongswan.android.ui.compose.ProfileAvatar
import org.strongswan.android.ui.compose.TrafficUi
import org.strongswan.android.ui.compose.formatBytes
import org.strongswan.android.ui.compose.formatDuration
import org.strongswan.android.ui.compose.formatRate
import androidx.compose.ui.unit.IntSize
import org.strongswan.android.ui.compose.theme.ShxSpacing
@Composable
internal fun ErrorBanner(modifier: Modifier = Modifier, onRetry: () -> Unit, onShowLog: () -> Unit)
{
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.connection_failed),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
maxLines = 2,
)
TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
TextButton(onClick = onShowLog) { Text(stringResource(R.string.show_log)) }
}
}
}
@Composable
internal fun WhiteListBanner(modifier: Modifier = Modifier)
{
val status = LocalShxStatusColors.current
Surface(
shape = MaterialTheme.shapes.large,
color = status.warningContainer,
contentColor = status.onWarningContainer,
modifier = modifier.fillMaxWidth(),
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(16.dp),
)
{
Icon(Icons.Rounded.GppMaybe, contentDescription = null)
Spacer(Modifier.size(12.dp))
Column {
Text(
text = stringResource(R.string.whitelist_active_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
text = stringResource(R.string.whitelist_active_subtitle),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
/** At most one status banner at a time: connection error beats the white-list notice. */
@Composable
internal fun HomeBannerSlot(
error: Boolean,
whiteListActive: Boolean,
onRetry: () -> Unit,
onShowLog: () -> Unit,
modifier: Modifier = Modifier,
)
{
val kind = when
{
error -> 1
whiteListActive -> 2
else -> 0
}
val effects = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
val fastEffects = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
val spatial = MaterialTheme.motionScheme.defaultSpatialSpec<IntSize>()
val fastSpatial = MaterialTheme.motionScheme.fastSpatialSpec<IntSize>()
AnimatedContent(
targetState = kind,
modifier = modifier,
transitionSpec = {
(fadeIn(effects) + expandVertically(spatial)) togetherWith
(fadeOut(fastEffects) + shrinkVertically(fastSpatial))
},
label = "homeBanner",
) { k ->
when (k)
{
1 -> ErrorBanner(Modifier.padding(horizontal = ShxSpacing.lg), onRetry = onRetry, onShowLog = onShowLog)
2 -> WhiteListBanner(Modifier.padding(horizontal = ShxSpacing.lg))
else -> Spacer(Modifier.height(0.dp))
}
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose.home
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import org.strongswan.android.R
import org.strongswan.android.logic.connectivity.ProbeResult
import org.strongswan.android.logic.connectivity.ProbeTarget
/* probe helpers of the connection details (left from the old availability card) */
internal val PROBE_ORDER = listOf(ProbeTarget.SERVER, ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE)
/** Server first, then external targets in fixed order. */
internal fun Map<ProbeTarget, ProbeResult>.sortedForDisplay(): List<ProbeResult> =
PROBE_ORDER.filter { containsKey(it) }.mapNotNull { this[it] }
internal fun ProbeTarget.labelRes(): Int = when (this)
{
ProbeTarget.SERVER -> R.string.probe_target_server
ProbeTarget.GOOGLE -> R.string.probe_target_google
ProbeTarget.YOUTUBE -> R.string.probe_target_youtube
ProbeTarget.CLOUDFLARE -> R.string.probe_target_cloudflare
}
@Composable
internal fun ProbeResult.rttText(): String = when
{
ok && rttMs != null -> stringResource(R.string.probe_rtt_fmt, rttMs)
else -> stringResource(R.string.value_no_data)
}
/* samples kept per probe target for the connection details */
internal const val RTT_HISTORY = 16
@@ -1,108 +0,0 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose.home
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowDownward
import androidx.compose.material.icons.rounded.ArrowUpward
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.ui.compose.TrafficUi
import org.strongswan.android.ui.compose.formatBytes
import org.strongswan.android.ui.compose.formatRate
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** Two compact tiles (down / up) with total and live rate; tap opens the connection details. */
@Composable
internal fun TrafficTiles(traffic: TrafficUi, onClick: () -> Unit, modifier: Modifier = Modifier)
{
Column(modifier = modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg))
{
Row(
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
)
{
TrafficTile(down = true, total = traffic.bytesIn, rate = traffic.rateIn, onClick = onClick, modifier = Modifier.weight(1f))
TrafficTile(down = false, total = traffic.bytesOut, rate = traffic.rateOut, onClick = onClick, modifier = Modifier.weight(1f))
}
/* one-glance confirmation that the RU split is working */
traffic.split?.takeIf { it.directBytes > 0 }?.let { split ->
Text(
text = stringResource(R.string.traffic_direct_share_fmt, split.directShare),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(top = ShxSpacing.sm),
)
}
}
}
@Composable
private fun TrafficTile(down: Boolean, total: Long, rate: Long, onClick: () -> Unit, modifier: Modifier = Modifier)
{
val label = if (down) stringResource(R.string.traffic_down) else stringResource(R.string.traffic_up)
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = modifier,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
modifier = Modifier.padding(ShxSpacing.md),
)
{
Surface(shape = CircleShape, color = MaterialTheme.colorScheme.surfaceContainerHigh)
{
Icon(
imageVector = if (down) Icons.Rounded.ArrowDownward else Icons.Rounded.ArrowUpward,
contentDescription = label,
modifier = Modifier.padding(ShxSpacing.sm - 2.dp).size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Column {
Text(
text = formatBytes(total),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Text(
text = formatRate(rate),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}