android: remove the pre-mockup Home parts (hero, banners, availability card, traffic tiles, glow)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
15ad5c1151
commit
92627da459
7 files changed
+43
-1298
No files matched your search
-57
@@ -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(
|
||||
|
||||
-80
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
-575
@@ -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,
|
||||
)
|
||||
}
|
||||
-244
@@ -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())
|
||||
}
|
||||
}
|
||||
-234
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -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
|
||||
|
||||
-108
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user