From 385819caf0e33fd95827656050a661a4cba50c67 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 10 Oct 2026 00:05:34 +0700 Subject: [PATCH] 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 --- .../android/ui/compose/HomeScreen.kt | 902 ++++-------------- .../ui/compose/home/AvailabilityCard.kt | 575 +++++++++++ .../android/ui/compose/home/HeroConnect.kt | 244 +++++ .../android/ui/compose/home/HomeBanners.kt | 234 +++++ .../android/ui/compose/home/TrafficTiles.kt | 108 +++ .../app/src/main/res/values-ru/strings.xml | 4 + .../app/src/main/res/values/strings.xml | 4 + 7 files changed, 1365 insertions(+), 706 deletions(-) create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/AvailabilityCard.kt create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HeroConnect.kt create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeBanners.kt create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/TrafficTiles.kt diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt index 19c62cb81..39ff71ef2 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt @@ -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(null) } var sheetProfile by remember { mutableStateOf(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() + val spatial = MaterialTheme.motionScheme.defaultSpatialSpec() 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, + 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.sortedForDisplay(): List = - 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, - probing: Boolean, - whiteList: WhiteListState, - history: Map>, - deadTargets: List, - 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 = 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) -{ - 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 diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/AvailabilityCard.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/AvailabilityCard.kt new file mode 100644 index 000000000..6e42319f0 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/AvailabilityCard.kt @@ -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.sortedForDisplay(): List = + 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 = 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) +{ + 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, + probing: Boolean, + whiteList: WhiteListState, + history: Map>, + deadTargets: List, + 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, 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, 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, + ) +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HeroConnect.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HeroConnect.kt new file mode 100644 index 000000000..b346e8ba6 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HeroConnect.kt @@ -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()) + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeBanners.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeBanners.kt new file mode 100644 index 000000000..41258db5a --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeBanners.kt @@ -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() + val fastEffects = MaterialTheme.motionScheme.fastEffectsSpec() + val spatial = MaterialTheme.motionScheme.defaultSpatialSpec() + val fastSpatial = MaterialTheme.motionScheme.fastSpatialSpec() + 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)) + } + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/TrafficTiles.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/TrafficTiles.kt new file mode 100644 index 000000000..fad716f31 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/TrafficTiles.kt @@ -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, + ) + } + } + } +} diff --git a/src/frontends/android/app/src/main/res/values-ru/strings.xml b/src/frontends/android/app/src/main/res/values-ru/strings.xml index 60d6c856e..d794091b0 100644 --- a/src/frontends/android/app/src/main/res/values-ru/strings.xml +++ b/src/frontends/android/app/src/main/res/values-ru/strings.xml @@ -487,4 +487,8 @@ Расширенная диагностика Периодические снимки диагностики маршрутизации во время подключения (каждые 5 минут); увеличивает расход батареи + Выбор профиля + Управление профилями + Показать детали + Скрыть детали diff --git a/src/frontends/android/app/src/main/res/values/strings.xml b/src/frontends/android/app/src/main/res/values/strings.xml index 88381edc1..a9eb360e5 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -492,4 +492,8 @@ Extended diagnostics Periodic routing diagnostics snapshots while connected (every 5 min); increases battery use + Choose profile + Manage profiles + Show details + Hide details