From 92627da459d45edd8ad7ea7cfaecab1a7accc93d Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 12 Oct 2026 14:47:33 +0700 Subject: [PATCH] android: remove the pre-mockup Home parts (hero, banners, availability card, traffic tiles, glow) Co-Authored-By: Claude Opus 5.5 --- .../android/ui/compose/HomeScreen.kt | 57 -- .../ui/compose/components/ShxListRow.kt | 80 --- .../ui/compose/home/AvailabilityCard.kt | 575 ------------------ .../android/ui/compose/home/HeroConnect.kt | 244 -------- .../android/ui/compose/home/HomeBanners.kt | 234 ------- .../android/ui/compose/home/ProbeFormat.kt | 43 ++ .../android/ui/compose/home/TrafficTiles.kt | 108 ---- 7 files changed, 43 insertions(+), 1298 deletions(-) delete mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxListRow.kt delete mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/AvailabilityCard.kt delete mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HeroConnect.kt delete 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/ProbeFormat.kt delete 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 99af0d24a..94c7ee221 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 @@ -150,9 +150,6 @@ import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.IntSize -import org.strongswan.android.ui.compose.home.AvailabilityCard -import org.strongswan.android.ui.compose.home.HeroConnect -import org.strongswan.android.ui.compose.home.HomeBannerSlot import org.strongswan.android.ui.compose.home.DEFAULT_PAUSE_MINUTES import org.strongswan.android.ui.compose.home.HomeProblem import org.strongswan.android.ui.compose.home.PauseSheet @@ -167,9 +164,7 @@ import org.strongswan.android.logic.events.EventLog import android.content.Intent import android.os.Build import android.provider.Settings -import org.strongswan.android.ui.compose.home.ProfileChip import org.strongswan.android.ui.compose.home.RTT_HISTORY -import org.strongswan.android.ui.compose.home.TrafficTiles import org.strongswan.android.ui.compose.home.labelRes import org.strongswan.android.ui.compose.home.rttText import org.strongswan.android.ui.compose.home.sortedForDisplay @@ -520,58 +515,6 @@ private fun ProfilePickerSheet( } } -/** Soft state-colored glow behind the hero; the pulse is skipped when system animations are off. */ -@Composable -private fun StatusGlowBackground(color: Color, active: Boolean) -{ - val context = LocalContext.current - val animationsOn = remember { - android.provider.Settings.Global.getFloat( - context.contentResolver, - android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, - 1f, - ) != 0f - } - val animated by animateColorAsState(color, label = "glow") - if (!active) - { /* nothing to draw: no canvas and, above all, no infinite transition - * redrawing a transparent circle every frame while disconnected */ - return - } - if (animationsOn) - { - val transition = rememberInfiniteTransition(label = "pulse") - /* read inside the draw scope below so the pulse only invalidates drawing, - * instead of recomposing this composable on every frame */ - val pulse by transition.animateFloat( - initialValue = 0.30f, - targetValue = 0.55f, - animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing), RepeatMode.Reverse), - label = "a", - ) - Canvas(Modifier.fillMaxSize()) { drawGlow(animated, pulse) } - } - else - { - Canvas(Modifier.fillMaxSize()) { drawGlow(animated, 0.42f) } - } -} - -private fun DrawScope.drawGlow(color: Color, alpha: Float) -{ - val center = Offset(size.width / 2f, size.height * 0.16f) - val radius = size.minDimension * 0.62f - drawCircle( - brush = Brush.radialGradient( - colors = listOf(color.copy(alpha = alpha * 0.30f), Color.Transparent), - center = center, - radius = radius, - ), - radius = radius, - center = center, - ) -} - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ConnectionDetailsSheet( diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxListRow.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxListRow.kt deleted file mode 100644 index 33d9f536b..000000000 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxListRow.kt +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright (C) 2026 SHX VPN - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the - * Free Software Foundation; either version 2 of the License, or (at your - * option) any later version. - */ - -package org.strongswan.android.ui.compose.components - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import org.strongswan.android.ui.compose.theme.ShxSpacing - -/** - * Generic list row: optional leading slot, headline + supporting text, optional trailing slot. - * Minimum height 56dp keeps touch targets above 48dp. Clickable when [onClick] is set. - */ -@Composable -fun ShxListRow( - headline: String, - modifier: Modifier = Modifier, - supporting: String? = null, - enabled: Boolean = true, - leading: (@Composable () -> Unit)? = null, - trailing: (@Composable () -> Unit)? = null, - onClick: (() -> Unit)? = null, -) -{ - val clickable = if (onClick != null) Modifier.clickable(enabled = enabled, onClick = onClick) else Modifier - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - .fillMaxWidth() - .then(clickable) - .heightIn(min = 56.dp) - .padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md), - ) - { - if (leading != null) - { - leading() - } - Column( - modifier = Modifier - .weight(1f) - .padding(horizontal = if (leading != null || trailing != null) ShxSpacing.lg else 0.dp), - ) - { - Text( - headline, - style = MaterialTheme.typography.bodyLarge, - color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (supporting != null) - { - Text( - supporting, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - if (trailing != null) - { - trailing() - } - } -} 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 deleted file mode 100644 index 6e42319f0..000000000 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/AvailabilityCard.kt +++ /dev/null @@ -1,575 +0,0 @@ -/* - * Copyright (C) 2026 SHX VPN - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the - * Free Software Foundation; either version 2 of the License, or (at your - * option) any later version. - */ - -@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) - -package org.strongswan.android.ui.compose.home - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.AltRoute -import androidx.compose.material.icons.rounded.ArrowDownward -import androidx.compose.material.icons.rounded.ArrowUpward -import androidx.compose.material.icons.rounded.ChevronRight -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.Dns -import androidx.compose.material.icons.rounded.ExpandLess -import androidx.compose.material.icons.rounded.GppMaybe -import androidx.compose.material.icons.rounded.Language -import androidx.compose.material.icons.rounded.PlayArrow -import androidx.compose.material.icons.rounded.Public -import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material.icons.rounded.Schedule -import androidx.compose.material.icons.rounded.Share -import androidx.compose.material.icons.rounded.SignalCellularAlt -import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material.icons.rounded.Wifi -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.LoadingIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.flow.StateFlow -import org.strongswan.android.R -import org.strongswan.android.data.VpnProfile -import org.strongswan.android.logic.VpnStateService.ErrorState -import org.strongswan.android.logic.VpnStateService.State -import org.strongswan.android.logic.connectivity.ProbeResult -import org.strongswan.android.logic.connectivity.ProbeTarget -import org.strongswan.android.logic.connectivity.WhiteListState -import org.strongswan.android.logic.connectivity.WhiteListStatus -import org.strongswan.android.ui.compose.components.ShxCard -import org.strongswan.android.ui.compose.components.ShxEmptyState -import org.strongswan.android.ui.compose.theme.LocalShxStatusColors -import org.strongswan.android.ui.compose.theme.ScreenPadding -import org.strongswan.android.ui.compose.DeadTargetUi -import org.strongswan.android.ui.compose.ExitUi -import org.strongswan.android.ui.compose.ShxDivider -import org.strongswan.android.ui.compose.ProfileAvatar -import org.strongswan.android.ui.compose.TrafficUi -import org.strongswan.android.ui.compose.formatBytes -import org.strongswan.android.ui.compose.formatDuration -import org.strongswan.android.ui.compose.formatRate - -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.clickable -import androidx.compose.material.icons.rounded.ExpandMore -import androidx.compose.runtime.saveable.rememberSaveable -import org.strongswan.android.ui.compose.theme.ShxSpacing - -/* ---------------- availability card ---------------- */ - -internal val PROBE_ORDER = listOf(ProbeTarget.SERVER, ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE) - -/** Server first, then external targets in fixed order. */ -internal fun Map.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 deleted file mode 100644 index b346e8ba6..000000000 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HeroConnect.kt +++ /dev/null @@ -1,244 +0,0 @@ -/* - * Copyright (C) 2026 SHX VPN - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the - * Free Software Foundation; either version 2 of the License, or (at your - * option) any later version. - */ - -@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) - -package org.strongswan.android.ui.compose.home - -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.Box -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Language -import androidx.compose.material.icons.rounded.PowerSettingsNew -import androidx.compose.material.icons.rounded.UnfoldMore -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.LoadingIndicator -import androidx.compose.material3.MaterialShapes -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Outline -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.asComposePath -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.LayoutDirection -import androidx.graphics.shapes.Morph -import androidx.graphics.shapes.toPath -import org.strongswan.android.R -import org.strongswan.android.data.VpnProfile -import org.strongswan.android.logic.VpnStateService.State -import org.strongswan.android.ui.compose.ExitUi -import org.strongswan.android.ui.compose.ProfileAvatar -import org.strongswan.android.ui.compose.theme.LocalShxStatusColors -import org.strongswan.android.ui.compose.theme.ShxSpacing - -/** - * The main connect control: a large expressive shape that morphs between a circle - * (disconnected), a scalloped cookie (connecting) and the full cookie (connected), - * with the state color and a loading indicator while the tunnel is coming up. - */ -@Composable -internal fun HeroConnect( - state: State, - enabled: Boolean, - onConnect: () -> Unit, - onDisconnect: () -> Unit, - onCancel: () -> Unit, - modifier: Modifier = Modifier, -) -{ - val status = LocalShxStatusColors.current - val connected = state == State.CONNECTED - val connecting = state == State.CONNECTING || state == State.DISCONNECTING - val haptics = LocalHapticFeedback.current - - val container by animateColorAsState( - targetValue = when - { - connected -> status.connectedContainer - connecting -> status.connectingContainer - else -> MaterialTheme.colorScheme.primary - }, - animationSpec = MaterialTheme.motionScheme.defaultEffectsSpec(), - label = "heroContainer", - ) - val content by animateColorAsState( - targetValue = when - { - connected -> status.connected - connecting -> status.connecting - else -> MaterialTheme.colorScheme.onPrimary - }, - animationSpec = MaterialTheme.motionScheme.defaultEffectsSpec(), - label = "heroContent", - ) - val progress by animateFloatAsState( - targetValue = when - { - connected -> 1f - connecting -> 0.5f - else -> 0f - }, - animationSpec = MaterialTheme.motionScheme.slowSpatialSpec(), - label = "heroMorph", - ) - val morph = remember { Morph(MaterialShapes.Circle, MaterialShapes.Cookie9Sided) } - val shape = remember(progress) { MorphShape(morph, progress) } - - val label = stringResource( - when - { - connected -> R.string.disconnect - connecting -> android.R.string.cancel - else -> R.string.connect - } - ) - Surface( - onClick = { - when - { - connected -> - { - haptics.performHapticFeedback(HapticFeedbackType.Reject) - onDisconnect() - } - connecting -> onCancel() - else -> - { - haptics.performHapticFeedback(HapticFeedbackType.Confirm) - onConnect() - } - } - }, - enabled = enabled || connected || connecting, - shape = shape, - color = container, - contentColor = content, - modifier = modifier - .size(176.dp) - .semantics { - contentDescription = label - role = Role.Button - }, - ) - { - Box(contentAlignment = Alignment.Center) - { - if (connecting) - { - LoadingIndicator(Modifier.size(72.dp), color = content) - } - else - { - Icon( - Icons.Rounded.PowerSettingsNew, - contentDescription = null, - modifier = Modifier.size(72.dp), - ) - } - } - } -} - -/** Current profile as a chip: name, and exit country + IP while connected; tap opens the picker. */ -@Composable -internal fun ProfileChip( - profile: VpnProfile?, - exit: ExitUi?, - connected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) -{ - val secondary = when - { - connected && exit?.ip != null -> exit.country?.let { "$it · ${exit.ip}" } ?: exit.ip - else -> profile?.gateway ?: "" - } - Surface( - onClick = onClick, - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainerLow, - modifier = modifier.fillMaxWidth(), - ) - { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md), - modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md), - ) - { - ProfileAvatar(icon = Icons.Rounded.Language, connected = connected) - Column(Modifier.weight(1f)) - { - Text( - text = profile?.name ?: stringResource(R.string.no_profiles), - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (!secondary.isNullOrEmpty()) - { - Text( - text = secondary, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - Icon( - Icons.Rounded.UnfoldMore, - contentDescription = stringResource(R.string.home_profile_picker_title), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -/** Compose Shape drawing a [Morph] at [progress], scaled to fill the layout bounds. */ -private class MorphShape(private val morph: Morph, private val progress: Float) : Shape -{ - override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline - { - val bounds = morph.calculateBounds() - val width = bounds[2] - bounds[0] - val height = bounds[3] - bounds[1] - val matrix = android.graphics.Matrix() - matrix.setScale(size.width / width, size.height / height) - matrix.preTranslate(-bounds[0], -bounds[1]) - val path = morph.toPath(progress) - path.transform(matrix) - return Outline.Generic(path.asComposePath()) - } -} 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 deleted file mode 100644 index 12da8f96a..000000000 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/HomeBanners.kt +++ /dev/null @@ -1,234 +0,0 @@ -/* - * Copyright (C) 2026 SHX VPN - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the - * Free Software Foundation; either version 2 of the License, or (at your - * option) any later version. - */ - -@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) - -package org.strongswan.android.ui.compose.home - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.AltRoute -import androidx.compose.material.icons.rounded.ArrowDownward -import androidx.compose.material.icons.rounded.ArrowUpward -import androidx.compose.material.icons.rounded.ChevronRight -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.Dns -import androidx.compose.material.icons.rounded.ExpandLess -import androidx.compose.material.icons.rounded.GppMaybe -import androidx.compose.material.icons.rounded.Language -import androidx.compose.material.icons.rounded.PlayArrow -import androidx.compose.material.icons.rounded.Public -import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material.icons.rounded.Schedule -import androidx.compose.material.icons.rounded.Share -import androidx.compose.material.icons.rounded.SignalCellularAlt -import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material.icons.rounded.Wifi -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.LoadingIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.flow.StateFlow -import org.strongswan.android.R -import org.strongswan.android.data.VpnProfile -import org.strongswan.android.logic.VpnStateService.ErrorState -import org.strongswan.android.logic.VpnStateService.State -import org.strongswan.android.logic.connectivity.ProbeResult -import org.strongswan.android.logic.connectivity.ProbeTarget -import org.strongswan.android.logic.connectivity.WhiteListState -import org.strongswan.android.logic.connectivity.WhiteListStatus -import org.strongswan.android.ui.compose.components.ShxCard -import org.strongswan.android.ui.compose.components.ShxEmptyState -import org.strongswan.android.ui.compose.theme.LocalShxStatusColors -import org.strongswan.android.ui.compose.theme.ScreenPadding -import org.strongswan.android.ui.compose.DeadTargetUi -import org.strongswan.android.ui.compose.ExitUi -import org.strongswan.android.ui.compose.ShxDivider -import org.strongswan.android.ui.compose.ProfileAvatar -import org.strongswan.android.ui.compose.TrafficUi -import org.strongswan.android.ui.compose.formatBytes -import org.strongswan.android.ui.compose.formatDuration -import org.strongswan.android.ui.compose.formatRate - -import androidx.compose.ui.unit.IntSize -import org.strongswan.android.ui.compose.theme.ShxSpacing - -@Composable -internal fun ErrorBanner(modifier: Modifier = Modifier, onRetry: () -> Unit, onShowLog: () -> Unit) -{ - Surface( - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - modifier = modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.connection_failed), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - maxLines = 2, - ) - TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) } - TextButton(onClick = onShowLog) { Text(stringResource(R.string.show_log)) } - } - } -} - -@Composable -internal fun WhiteListBanner(modifier: Modifier = Modifier) -{ - val status = LocalShxStatusColors.current - Surface( - shape = MaterialTheme.shapes.large, - color = status.warningContainer, - contentColor = status.onWarningContainer, - modifier = modifier.fillMaxWidth(), - ) - { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(16.dp), - ) - { - Icon(Icons.Rounded.GppMaybe, contentDescription = null) - Spacer(Modifier.size(12.dp)) - Column { - Text( - text = stringResource(R.string.whitelist_active_title), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = stringResource(R.string.whitelist_active_subtitle), - style = MaterialTheme.typography.bodySmall, - ) - } - } - } -} - -/** At most one status banner at a time: connection error beats the white-list notice. */ -@Composable -internal fun HomeBannerSlot( - error: Boolean, - whiteListActive: Boolean, - onRetry: () -> Unit, - onShowLog: () -> Unit, - modifier: Modifier = Modifier, -) -{ - val kind = when - { - error -> 1 - whiteListActive -> 2 - else -> 0 - } - val effects = MaterialTheme.motionScheme.defaultEffectsSpec() - 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/ProbeFormat.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/ProbeFormat.kt new file mode 100644 index 000000000..9666b22bb --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/ProbeFormat.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. + */ + + +package org.strongswan.android.ui.compose.home + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import org.strongswan.android.R +import org.strongswan.android.logic.connectivity.ProbeResult +import org.strongswan.android.logic.connectivity.ProbeTarget + +/* probe helpers of the connection details (left from the old availability card) */ +internal val PROBE_ORDER = listOf(ProbeTarget.SERVER, ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE) + +/** Server first, then external targets in fixed order. */ +internal fun Map.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) +} + +/* samples kept per probe target for the connection details */ +internal const val RTT_HISTORY = 16 + 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 deleted file mode 100644 index fad716f31..000000000 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/home/TrafficTiles.kt +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright (C) 2026 SHX VPN - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the - * Free Software Foundation; either version 2 of the License, or (at your - * option) any later version. - */ - -package org.strongswan.android.ui.compose.home - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ArrowDownward -import androidx.compose.material.icons.rounded.ArrowUpward -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import org.strongswan.android.R -import org.strongswan.android.ui.compose.TrafficUi -import org.strongswan.android.ui.compose.formatBytes -import org.strongswan.android.ui.compose.formatRate -import org.strongswan.android.ui.compose.theme.ShxSpacing - -/** Two compact tiles (down / up) with total and live rate; tap opens the connection details. */ -@Composable -internal fun TrafficTiles(traffic: TrafficUi, onClick: () -> Unit, modifier: Modifier = Modifier) -{ - Column(modifier = modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg)) - { - Row( - horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md), - modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), - ) - { - TrafficTile(down = true, total = traffic.bytesIn, rate = traffic.rateIn, onClick = onClick, modifier = Modifier.weight(1f)) - TrafficTile(down = false, total = traffic.bytesOut, rate = traffic.rateOut, onClick = onClick, modifier = Modifier.weight(1f)) - } - /* one-glance confirmation that the RU split is working */ - traffic.split?.takeIf { it.directBytes > 0 }?.let { split -> - Text( - text = stringResource(R.string.traffic_direct_share_fmt, split.directShare), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(top = ShxSpacing.sm), - ) - } - } -} - -@Composable -private fun TrafficTile(down: Boolean, total: Long, rate: Long, onClick: () -> Unit, modifier: Modifier = Modifier) -{ - val label = if (down) stringResource(R.string.traffic_down) else stringResource(R.string.traffic_up) - Surface( - onClick = onClick, - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainerLow, - modifier = modifier, - ) - { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md), - modifier = Modifier.padding(ShxSpacing.md), - ) - { - Surface(shape = CircleShape, color = MaterialTheme.colorScheme.surfaceContainerHigh) - { - Icon( - imageVector = if (down) Icons.Rounded.ArrowDownward else Icons.Rounded.ArrowUpward, - contentDescription = label, - modifier = Modifier.padding(ShxSpacing.sm - 2.dp).size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Column { - Text( - text = formatBytes(total), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Medium, - ) - Text( - text = formatRate(rate), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -}