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:
1 parent
3f7bd94a69
commit
385819caf0
7 files changed
+1365
-706
No files matched your search
+196
-706
@@ -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.components.ShxEmptyState
|
||||||
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
|
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
|
||||||
import org.strongswan.android.ui.compose.theme.ScreenPadding
|
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)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -169,6 +186,7 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
var pendingDelete by remember { mutableStateOf<VpnProfile?>(null) }
|
var pendingDelete by remember { mutableStateOf<VpnProfile?>(null) }
|
||||||
var sheetProfile 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 connected = state.vpnState == State.CONNECTED
|
||||||
val connecting = state.vpnState == State.CONNECTING || state.vpnState == State.DISCONNECTING
|
val connecting = state.vpnState == State.CONNECTING || state.vpnState == State.DISCONNECTING
|
||||||
val hasError = state.error != ErrorState.NO_ERROR
|
val hasError = state.error != ErrorState.NO_ERROR
|
||||||
@@ -196,6 +214,9 @@ fun HomeScreen(
|
|||||||
connecting -> selected?.name ?: ""
|
connecting -> selected?.name ?: ""
|
||||||
else -> stringResource(R.string.status_choose_profile)
|
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())
|
Box(Modifier.fillMaxSize())
|
||||||
{
|
{
|
||||||
@@ -203,26 +224,35 @@ fun HomeScreen(
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
/* the adaptive navigation scaffold no longer pads the content area,
|
/* 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(),
|
modifier = Modifier.fillMaxSize().statusBarsPadding(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
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(
|
Column(
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 24.dp, bottom = 12.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
|
||||||
) {
|
) {
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = statusText,
|
targetState = statusText,
|
||||||
transitionSpec = {
|
transitionSpec = { fadeIn(effects) togetherWith fadeOut(effects) },
|
||||||
(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)))
|
|
||||||
},
|
|
||||||
label = "statusText",
|
label = "statusText",
|
||||||
) { text ->
|
) { text ->
|
||||||
Text(
|
Text(
|
||||||
@@ -231,87 +261,19 @@ fun HomeScreen(
|
|||||||
color = statusColor,
|
color = statusColor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(ShxSpacing.xs))
|
||||||
Text(
|
Text(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (state.profiles.isEmpty())
|
if (state.profiles.isEmpty())
|
||||||
{
|
{
|
||||||
item(key = "empty") {
|
item(key = "empty") {
|
||||||
ShxCard(
|
ShxCard(modifier = Modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg)) {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = ScreenPadding, vertical = 12.dp),
|
|
||||||
) {
|
|
||||||
ShxEmptyState(
|
ShxEmptyState(
|
||||||
icon = Icons.Rounded.Language,
|
icon = Icons.Rounded.Language,
|
||||||
message = stringResource(R.string.no_profiles),
|
message = stringResource(R.string.no_profiles),
|
||||||
@@ -323,30 +285,83 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
itemsIndexed(state.profiles, key = { _, p -> p.getUUID().toString() }) { index, profile ->
|
item(key = "chip") {
|
||||||
val active = connected && profile.getUUID() == (state.activeProfile ?: selected)?.getUUID()
|
ProfileChip(
|
||||||
Column(Modifier.animateItem()) {
|
profile = selected,
|
||||||
ShxProfileRow(
|
exit = exit,
|
||||||
profile = profile,
|
connected = connected,
|
||||||
active = active,
|
onClick = { pickerOpen = true },
|
||||||
onClick = {
|
modifier = Modifier.padding(horizontal = ShxSpacing.lg).animateItem(),
|
||||||
viewModel.select(profile)
|
)
|
||||||
viewModel.connect(context, profile)
|
|
||||||
},
|
|
||||||
onLongClick = { sheetProfile = profile },
|
|
||||||
onLongClickLabel = stringResource(R.string.profile_actions),
|
|
||||||
)
|
|
||||||
if (index < state.profiles.lastIndex)
|
|
||||||
{
|
|
||||||
ShxDivider()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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)
|
if (state.detailsOpen)
|
||||||
{
|
{
|
||||||
ConnectionDetailsSheet(
|
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
|
@Composable
|
||||||
private fun StatusGlowBackground(color: Color, active: Boolean)
|
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 animated by animateColorAsState(color, label = "glow")
|
||||||
val transition = rememberInfiniteTransition(label = "pulse")
|
val transition = rememberInfiniteTransition(label = "pulse")
|
||||||
/* read inside the draw scope below so the pulse only invalidates drawing,
|
/* 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())
|
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 cx = size.width / 2f
|
||||||
val cy = size.height * 0.16f
|
val cy = size.height * 0.16f
|
||||||
drawCircle(
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ConnectionDetailsSheet(
|
private fun ConnectionDetailsSheet(
|
||||||
@@ -825,401 +713,3 @@ private fun SheetAction(icon: ImageVector, label: String, onClick: () -> Unit, t
|
|||||||
Text(label, style = MaterialTheme.typography.bodyLarge)
|
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
|
|
||||||
+575
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
+244
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
+234
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
@@ -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_title">Расширенная диагностика</string>
|
||||||
<string name="extended_diag_summary">Периодические снимки диагностики маршрутизации во время подключения (каждые 5 минут); увеличивает расход батареи</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>
|
</resources>
|
||||||
@@ -492,4 +492,8 @@
|
|||||||
<string name="extended_diag_title">Extended diagnostics</string>
|
<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="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>
|
</resources>
|
||||||
Reference in new issue
Block a user