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