android: redesigned Connect screen - morphing hero, profile chip + picker sheet, traffic tiles, one banner slot, collapsed availability

Split HomeScreen into home/{HeroConnect,TrafficTiles,AvailabilityCard,HomeBanners}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-10 00:05:34 +07:00
1 parent 3f7bd94a69
commit 385819caf0
7 files changed
+1365 -706

No files matched your search

@@ -125,6 +125,23 @@ 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 androidx.compose.material.icons.rounded.Check
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.semantics.LiveRegionMode
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.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
import org.strongswan.android.ui.compose.theme.ShxSpacing
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
@@ -169,6 +186,7 @@ fun HomeScreen(
}
var pendingDelete by remember { mutableStateOf<VpnProfile?>(null) }
var sheetProfile by remember { mutableStateOf<VpnProfile?>(null) }
var pickerOpen by remember { mutableStateOf(false) }
val connected = state.vpnState == State.CONNECTED
val connecting = state.vpnState == State.CONNECTING || state.vpnState == State.DISCONNECTING
val hasError = state.error != ErrorState.NO_ERROR
@@ -196,6 +214,9 @@ fun HomeScreen(
connecting -> selected?.name ?: ""
else -> stringResource(R.string.status_choose_profile)
}
val whiteListActive = whiteList.config.enabled && whiteList.status == WhiteListStatus.ACTIVE
val effects = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
val spatial = MaterialTheme.motionScheme.defaultSpatialSpec<IntSize>()
Box(Modifier.fillMaxSize())
{
@@ -203,26 +224,35 @@ fun HomeScreen(
LazyColumn(
state = listState,
/* the adaptive navigation scaffold no longer pads the content area,
* so the status headline offsets itself below the status bar */
* so the hero offsets itself below the status bar */
modifier = Modifier.fillMaxSize().statusBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(ShxSpacing.lg),
)
{
item(key = "status") {
item(key = "hero") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = ShxSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
) {
HeroConnect(
state = state.vpnState,
enabled = selected != null,
onConnect = { selected?.let { viewModel.connect(context, it) } },
onDisconnect = { viewModel.disconnect() },
onCancel = { viewModel.disconnect() },
)
Spacer(Modifier.height(ShxSpacing.lg))
/* the status is announced to TalkBack whenever it changes */
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp, bottom = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
) {
AnimatedContent(
targetState = statusText,
transitionSpec = {
(slideInVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMediumLow)) { it / 3 } +
fadeIn(tween(120))) togetherWith
(slideOutVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMedium)) { -it / 3 } +
fadeOut(tween(90)))
},
transitionSpec = { fadeIn(effects) togetherWith fadeOut(effects) },
label = "statusText",
) { text ->
Text(
@@ -231,87 +261,19 @@ fun HomeScreen(
color = statusColor,
)
}
Spacer(Modifier.height(6.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
item(key = "card") {
ConnectionCard(
modifier = Modifier.padding(horizontal = ScreenPadding),
profile = selected,
vpnState = state.vpnState,
traffic = traffic,
enabled = selected != null,
onConnect = { selected?.let { viewModel.connect(context, it) } },
onDisconnect = { viewModel.disconnect() },
onCancel = { viewModel.disconnect() },
onOpenDetails = { if (connected) viewModel.openDetails(true) else selected?.let { onEditProfile(it) } },
)
}
if (whiteList.config.enabled && whiteList.status == WhiteListStatus.ACTIVE)
{
item(key = "whitelist") {
WhiteListBanner(
modifier = Modifier.padding(top = 12.dp, start = ScreenPadding, end = ScreenPadding),
)
}
}
if (connected)
{
item(key = "availability") {
AvailabilityCard(
modifier = Modifier.padding(top = 12.dp),
probes = probes,
probing = probing,
whiteList = whiteList,
history = probeHistory,
deadTargets = deadTargets,
onOpenRouting = { viewModel.setTab(ShxTab.ROUTING) },
onForceTunnel = viewModel::forceTunnelDeadTarget,
onRefresh = { viewModel.refreshProbes() },
)
}
}
if (hasError)
{
item(key = "error") {
ErrorBanner(
modifier = Modifier.padding(top = 12.dp, start = ScreenPadding, end = ScreenPadding),
onRetry = { viewModel.reconnect() },
onShowLog = onShowLog,
)
}
}
item(key = "header") {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 28.dp, bottom = 4.dp, start = ScreenPadding, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(R.string.profiles_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onAddProfile) {
Icon(Icons.Rounded.Add, contentDescription = stringResource(R.string.add_profile_cta))
Spacer(Modifier.height(ShxSpacing.xs))
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (state.profiles.isEmpty())
{
item(key = "empty") {
ShxCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ScreenPadding, vertical = 12.dp),
) {
ShxCard(modifier = Modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg)) {
ShxEmptyState(
icon = Icons.Rounded.Language,
message = stringResource(R.string.no_profiles),
@@ -323,30 +285,83 @@ fun HomeScreen(
}
else
{
itemsIndexed(state.profiles, key = { _, p -> p.getUUID().toString() }) { index, profile ->
val active = connected && profile.getUUID() == (state.activeProfile ?: selected)?.getUUID()
Column(Modifier.animateItem()) {
ShxProfileRow(
profile = profile,
active = active,
onClick = {
viewModel.select(profile)
viewModel.connect(context, profile)
},
onLongClick = { sheetProfile = profile },
onLongClickLabel = stringResource(R.string.profile_actions),
)
if (index < state.profiles.lastIndex)
{
ShxDivider()
}
}
item(key = "chip") {
ProfileChip(
profile = selected,
exit = exit,
connected = connected,
onClick = { pickerOpen = true },
modifier = Modifier.padding(horizontal = ShxSpacing.lg).animateItem(),
)
}
item(key = "space") { Spacer(Modifier.height(24.dp)) }
}
item(key = "traffic") {
AnimatedVisibility(
visible = connected,
enter = fadeIn(effects) + expandVertically(spatial),
exit = fadeOut(effects) + shrinkVertically(spatial),
) {
TrafficTiles(traffic = traffic, onClick = { viewModel.openDetails(true) })
}
}
item(key = "banner") {
HomeBannerSlot(
error = hasError,
whiteListActive = whiteListActive,
onRetry = { viewModel.reconnect() },
onShowLog = onShowLog,
)
}
if (connected)
{
item(key = "availability") {
AvailabilityCard(
probes = probes,
probing = probing,
whiteList = whiteList,
history = probeHistory,
deadTargets = deadTargets,
onOpenRouting = { viewModel.setTab(ShxTab.ROUTING) },
onForceTunnel = viewModel::forceTunnelDeadTarget,
onRefresh = { viewModel.refreshProbes() },
)
}
}
item(key = "space") { Spacer(Modifier.height(ShxSpacing.xl)) }
}
}
if (pickerOpen)
{
ProfilePickerSheet(
profiles = state.profiles,
selected = selected,
activeUuid = if (connected) (state.activeProfile ?: selected)?.getUUID() else null,
onDismiss = { pickerOpen = false },
onPick = { profile ->
pickerOpen = false
val alreadyActive = connected && profile.getUUID() == (state.activeProfile ?: selected)?.getUUID()
if (!alreadyActive)
{
viewModel.select(profile)
viewModel.connect(context, profile)
}
},
onLongPick = { profile ->
pickerOpen = false
sheetProfile = profile
},
onManage = {
pickerOpen = false
viewModel.setTab(ShxTab.PROFILES)
},
onAdd = {
pickerOpen = false
onAddProfile()
},
)
}
if (state.detailsOpen)
{
ConnectionDetailsSheet(
@@ -408,9 +423,84 @@ fun HomeScreen(
}
}
/** Bottom sheet to switch the profile without leaving the Connect tab. */
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
private fun ProfilePickerSheet(
profiles: List<VpnProfile>,
selected: VpnProfile?,
activeUuid: java.util.UUID?,
onDismiss: () -> Unit,
onPick: (VpnProfile) -> Unit,
onLongPick: (VpnProfile) -> Unit,
onManage: () -> Unit,
onAdd: () -> Unit,
)
{
/* the current profile first, the rest in list order */
val ordered = remember(profiles, selected) {
profiles.sortedByDescending { it.getUUID() == selected?.getUUID() }
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
)
{
Column(Modifier.fillMaxWidth())
{
Text(
text = stringResource(R.string.home_profile_picker_title),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.sm).semantics { heading() },
)
ordered.forEachIndexed { index, profile ->
ShxProfileRow(
profile = profile,
active = profile.getUUID() == activeUuid,
onClick = { onPick(profile) },
onLongClick = { onLongPick(profile) },
onLongClickLabel = stringResource(R.string.profile_actions),
trailing = if (profile.getUUID() == selected?.getUUID() && profile.getUUID() != activeUuid)
{
{ Icon(Icons.Rounded.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }
}
else null,
)
if (index < ordered.lastIndex)
{
ShxDivider()
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.sm),
horizontalArrangement = Arrangement.SpaceBetween,
)
{
TextButton(onClick = onManage) { Text(stringResource(R.string.home_manage_profiles)) }
TextButton(onClick = onAdd) {
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(ShxSpacing.xs))
Text(stringResource(R.string.add_profile_cta))
}
}
Spacer(Modifier.height(ShxSpacing.lg))
}
}
}
/** 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")
val transition = rememberInfiniteTransition(label = "pulse")
/* read inside the draw scope below so the pulse only invalidates drawing,
@@ -423,7 +513,7 @@ private fun StatusGlowBackground(color: Color, active: Boolean)
)
Canvas(Modifier.fillMaxSize())
{
val alpha = if (active) pulse else 0f
val alpha = if (active) (if (animationsOn) pulse else 0.42f) else 0f
val cx = size.width / 2f
val cy = size.height * 0.16f
drawCircle(
@@ -438,208 +528,6 @@ private fun StatusGlowBackground(color: Color, active: Boolean)
}
}
@Composable
private 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
private fun TrafficStat(down: Boolean, total: Long, rate: Long)
{
val label = if (down) stringResource(R.string.traffic_down) else stringResource(R.string.traffic_up)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
)
{
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Icon(
imageVector = if (down) Icons.Rounded.ArrowDownward else Icons.Rounded.ArrowUpward,
contentDescription = label,
modifier = Modifier
.padding(6.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,
)
}
}
}
/** The full-width rounded action button of the connection card, with a springy press-scale. */
@Composable
private fun HeroButton(
onClick: () -> Unit,
enabled: Boolean = true,
container: Color = MaterialTheme.colorScheme.surfaceContainerHighest,
content: Color = MaterialTheme.colorScheme.onSurface,
block: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,
)
{
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (pressed) 0.97f else 1f,
animationSpec = spring(dampingRatio = 0.55f, stiffness = Spring.StiffnessMediumLow),
label = "pressScale",
)
Button(
onClick = onClick,
enabled = enabled,
interactionSource = interaction,
colors = ButtonDefaults.buttonColors(containerColor = container, contentColor = content),
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.graphicsLayer {
scaleX = scale
scaleY = scale
},
) { block() }
}
@Composable
private fun ConnectionCard(
modifier: Modifier = Modifier,
profile: VpnProfile?,
vpnState: State,
traffic: TrafficUi,
enabled: Boolean,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
onCancel: () -> Unit,
onOpenDetails: () -> Unit,
)
{
val connecting = vpnState == State.CONNECTING || vpnState == State.DISCONNECTING
val connected = vpnState == State.CONNECTED
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerLow,
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = modifier.fillMaxWidth(),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp))
{
Row(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(enabled = profile != null, onClick = onOpenDetails),
verticalAlignment = Alignment.CenterVertically,
) {
ProfileAvatar(icon = Icons.Rounded.Language, connected = connected)
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
) {
Text(
text = profile?.name ?: stringResource(R.string.no_profiles),
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
)
Text(
text = profile?.gateway ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
Icon(
Icons.Rounded.ExpandLess,
contentDescription = stringResource(R.string.connection_details),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
AnimatedVisibility(
visible = connected,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Column {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
TrafficStat(down = true, total = traffic.bytesIn, rate = traffic.rateIn)
Box(
modifier = Modifier
.size(1.dp, 32.dp)
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)),
)
TrafficStat(down = false, total = traffic.bytesOut, rate = traffic.rateOut)
}
/* 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 = 6.dp),
)
}
}
}
when (vpnState)
{
State.CONNECTED -> HeroButton(
onClick = onDisconnect,
container = MaterialTheme.colorScheme.errorContainer,
content = MaterialTheme.colorScheme.onErrorContainer,
) { Text(stringResource(R.string.disconnect), fontSize = 16.sp) }
State.CONNECTING, State.DISCONNECTING -> HeroButton(onClick = onCancel) {
LoadingIndicator(Modifier.size(18.dp))
Spacer(Modifier.size(12.dp))
Text(stringResource(android.R.string.cancel), fontSize = 16.sp)
}
else -> HeroButton(
onClick = onConnect,
enabled = enabled,
container = MaterialTheme.colorScheme.primary,
content = MaterialTheme.colorScheme.onPrimary,
) { Text(stringResource(R.string.connect), fontSize = 16.sp) }
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ConnectionDetailsSheet(
@@ -825,401 +713,3 @@ private fun SheetAction(icon: ImageVector, label: String, onClick: () -> Unit, t
Text(label, style = MaterialTheme.typography.bodyLarge)
}
}
/* ---------------- availability card ---------------- */
private val PROBE_ORDER = listOf(ProbeTarget.SERVER, ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE)
/** Server first, then external targets in fixed order. */
private fun Map<ProbeTarget, ProbeResult>.sortedForDisplay(): List<ProbeResult> =
PROBE_ORDER.filter { containsKey(it) }.mapNotNull { this[it] }
private 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
private fun ProbeResult.rttText(): String = when
{
ok && rttMs != null -> stringResource(R.string.probe_rtt_fmt, rttMs)
else -> stringResource(R.string.value_no_data)
}
@Composable
private 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,
)
{
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerLow,
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = modifier
.fillMaxWidth()
.padding(horizontal = ScreenPadding),
)
{
PullToRefreshBox(isRefreshing = probing, onRefresh = onRefresh)
{
Column(Modifier.padding(16.dp))
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
)
{
Text(
text = stringResource(R.string.availability_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
if (probing)
{
LoadingIndicator(Modifier.size(14.dp))
Spacer(Modifier.size(12.dp))
}
IconButton(onClick = onRefresh, enabled = !probing) {
Icon(
Icons.Rounded.Refresh,
contentDescription = stringResource(R.string.availability_refresh),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.height(4.dp))
val rows = probes.sortedForDisplay()
if (rows.isEmpty())
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(top = 4.dp),
)
{
LoadingIndicator(Modifier.size(14.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())
{
Spacer(Modifier.height(8.dp))
ShxDivider()
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = onOpenRouting)
.padding(vertical = 4.dp),
) {
Box(
modifier = 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 = 4.dp),
)
}
}
if (whiteList.config.enabled)
{
Spacer(Modifier.height(8.dp))
ShxDivider()
Spacer(Modifier.height(8.dp))
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,
)
}
}
}
}
}
/** A dead direct destination: address, the app hammering it, refusal stats,
* and a one-tap action to force the host through the tunnel. */
@Composable
private 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
private 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
private fun WhiteListBanner(modifier: Modifier = Modifier)
{
val status = LocalShxStatusColors.current
Surface(
shape = MaterialTheme.shapes.large,
color = status.connectingContainer,
contentColor = status.onConnectingContainer,
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,
)
}
}
}
}
@Composable
private 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,
)
}
}
}
private const val RTT_HISTORY = 16
/** Tiny bar chart of the last RTT samples; missing samples render as dim ticks. */
@Composable
private 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()),
)
}
}
}
}
private const val WARN_RTT_MS = 150L
private const val FAIL_RTT_MS = 400L
@@ -0,0 +1,575 @@
/*
* 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,
)
}
@@ -0,0 +1,244 @@
/*
* 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())
}
}
@@ -0,0 +1,234 @@
/*
* 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.connectingContainer,
contentColor = status.onConnectingContainer,
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,108 @@
/*
* 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,
)
}
}
}
}
@@ -487,4 +487,8 @@
<string name="extended_diag_title">Расширенная диагностика</string>
<string name="extended_diag_summary">Периодические снимки диагностики маршрутизации во время подключения (каждые 5 минут); увеличивает расход батареи</string>
<string name="home_profile_picker_title">Выбор профиля</string>
<string name="home_manage_profiles">Управление профилями</string>
<string name="availability_expand">Показать детали</string>
<string name="availability_collapse">Скрыть детали</string>
</resources>
@@ -492,4 +492,8 @@
<string name="extended_diag_title">Extended diagnostics</string>
<string name="extended_diag_summary">Periodic routing diagnostics snapshots while connected (every 5 min); increases battery use</string>
<string name="home_profile_picker_title">Choose profile</string>
<string name="home_manage_profiles">Manage profiles</string>
<string name="availability_expand">Show details</string>
<string name="availability_collapse">Hide details</string>
</resources>