android: mockup line icons everywhere, mockup dialog buttons, server picker sheet as on F_Servers; drop the unused old Home and routing parts

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-12 19:33:42 +07:00
1 parent 16d4c72920
commit 9ed278b440
19 files changed
+208 -1237

No files matched your search

@@ -40,9 +40,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -61,6 +58,7 @@ 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.design.ShxIcons
import org.strongswan.android.ui.compose.components.PredictiveBackScale
import org.strongswan.android.logic.imc.RemediationInstruction
import org.strongswan.android.ui.compose.components.ShxCard
@@ -143,7 +141,7 @@ private fun RemediationUi(
navigationIcon = {
IconButton(onClick = { if (selected != null) onSelect(null) else onFinish() }) {
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
ShxIcons.Back,
contentDescription = stringResource(R.string.back),
)
}
@@ -167,7 +165,7 @@ private fun RemediationUi(
if (instructions.isEmpty())
{
ShxEmptyState(
icon = Icons.Rounded.CheckCircle,
icon = ShxIcons.Check,
message = stringResource(R.string.remediation_instructions_title),
modifier = Modifier.padding(ShxSpacing.lg),
)
@@ -43,7 +43,6 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -80,6 +79,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.ui.compose.design.ShxDialogButton
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.data.VpnProfileSource
@@ -724,8 +724,8 @@ private fun TextDialog(
text = {
ShxFormField(label = label, value = text, onValueChange = { text = it }, keyboardOptions = KeyboardOptions(keyboardType = keyboard))
},
confirmButton = { TextButton(onClick = { onDone(text) }, enabled = validate(text.trim())) { Text(stringResource(R.string.server_save)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
confirmButton = { ShxDialogButton(primary = true, onClick = { onDone(text) }, enabled = validate(text.trim())) { Text(stringResource(R.string.server_save)) } },
dismissButton = { ShxDialogButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
)
}
@@ -27,7 +27,6 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import org.strongswan.android.ui.compose.design.ShxAlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -38,6 +37,7 @@ import java.security.cert.CertificateException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import org.strongswan.android.R
import org.strongswan.android.ui.compose.design.ShxDialogButton
import org.strongswan.android.logic.TrustedCertificateManager
import org.strongswan.android.ui.compose.theme.ShxTheme
@@ -180,7 +180,7 @@ class TrustedCertificateImportActivity : AppCompatActivity()
title = { Text(stringResource(R.string.import_certificate)) },
text = { Text(certificate.getSubjectDN().toString()) },
confirmButton = {
TextButton(onClick = {
ShxDialogButton(primary = true, onClick = {
if (storeCertificate(certificate))
{
Toast.makeText(this, R.string.cert_imported_successfully, Toast.LENGTH_LONG).show()
@@ -194,7 +194,7 @@ class TrustedCertificateImportActivity : AppCompatActivity()
}) { Text(stringResource(R.string.import_certificate)) }
},
dismissButton = {
TextButton(onClick = { finish() }) {
ShxDialogButton(onClick = { finish() }) {
Text(stringResource(android.R.string.cancel))
}
},
@@ -22,15 +22,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.BatterySaver
import androidx.compose.material.icons.rounded.HealthAndSafety
import androidx.compose.material.icons.rounded.NetworkCheck
import androidx.compose.material.icons.rounded.Remove
import androidx.compose.material.icons.rounded.Public
import androidx.compose.material.icons.rounded.Speed
import androidx.compose.material.icons.rounded.Timer
import org.strongswan.android.ui.compose.design.ShxAlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -40,7 +31,6 @@ import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -52,6 +42,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.ui.compose.design.ShxDialogButton
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.components.ShxSection
import org.strongswan.android.ui.compose.components.ShxSectionDivider
import org.strongswan.android.ui.compose.components.ShxSettingRow
@@ -92,7 +84,7 @@ fun ConnectivityCheckScreen()
ShxSection(title = stringResource(R.string.settings_section_general))
{
ShxSettingToggleRow(
icon = Icons.Rounded.NetworkCheck,
icon = ShxIcons.Pulse,
title = stringResource(R.string.health_check_title),
subtitle = stringResource(R.string.health_check_summary),
checked = config.enabled,
@@ -100,7 +92,7 @@ fun ConnectivityCheckScreen()
)
ShxSectionDivider()
ShxSettingRow(
icon = Icons.Rounded.Timer,
icon = ShxIcons.Clock,
title = stringResource(R.string.health_check_interval),
subtitle = intervalLabel(config.intervalSec, config.aggressiveMode),
enabled = config.enabled && !config.aggressiveMode,
@@ -108,7 +100,7 @@ fun ConnectivityCheckScreen()
)
ShxSectionDivider()
ShxSettingToggleRow(
icon = Icons.Rounded.Speed,
icon = ShxIcons.Bolt,
title = stringResource(R.string.health_check_aggressive_title),
subtitle = stringResource(R.string.health_check_aggressive_summary),
checked = config.aggressiveMode,
@@ -116,7 +108,7 @@ fun ConnectivityCheckScreen()
)
ShxSectionDivider()
ShxSettingToggleRow(
icon = Icons.Rounded.BatterySaver,
icon = ShxIcons.Battery,
title = stringResource(R.string.health_check_adaptive_title),
subtitle = stringResource(R.string.health_check_adaptive_summary),
checked = config.adaptiveMode,
@@ -127,7 +119,7 @@ fun ConnectivityCheckScreen()
ShxSection(title = stringResource(R.string.health_check_targets_title))
{
ShxSettingToggleRow(
icon = Icons.Rounded.HealthAndSafety,
icon = ShxIcons.Shield,
title = stringResource(R.string.health_check_server_title),
subtitle = stringResource(R.string.health_check_server_summary),
checked = config.serverCheckMode != ServerCheckMode.OFF,
@@ -140,7 +132,7 @@ fun ConnectivityCheckScreen()
listOf(ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE).forEach { target ->
ShxSectionDivider()
ShxSettingToggleRow(
icon = Icons.Rounded.Public,
icon = ShxIcons.Globe,
title = targetLabel(target),
subtitle = null,
checked = config.targets.contains(target),
@@ -175,7 +167,7 @@ fun ConnectivityCheckScreen()
}
ShxSectionDivider()
ShxSettingRow(
icon = Icons.Rounded.NetworkCheck,
icon = ShxIcons.Pulse,
title = stringResource(R.string.health_check_action),
subtitle = actionLabel(config.serverAction),
enabled = config.enabled,
@@ -203,7 +195,7 @@ fun ConnectivityCheckScreen()
ShxSection(title = stringResource(R.string.health_check_tunnel_dead_title))
{
ShxSettingRow(
icon = Icons.Rounded.NetworkCheck,
icon = ShxIcons.Pulse,
title = stringResource(R.string.health_check_action),
subtitle = actionLabel(config.tunnelDeadAction),
enabled = config.enabled,
@@ -231,7 +223,7 @@ fun ConnectivityCheckScreen()
ShxSection(title = stringResource(R.string.health_check_partial_title))
{
ShxSettingRow(
icon = Icons.Rounded.NetworkCheck,
icon = ShxIcons.Pulse,
title = stringResource(R.string.health_check_action),
subtitle = actionLabel(config.partialDownAction),
enabled = config.enabled,
@@ -276,7 +268,7 @@ fun ConnectivityCheckScreen()
}
},
confirmButton = {
TextButton(onClick = { showIntervalPicker = false }) {
ShxDialogButton(onClick = { showIntervalPicker = false }) {
Text(stringResource(android.R.string.cancel))
}
},
@@ -341,7 +333,7 @@ private fun AttemptsRow(
modifier = Modifier.weight(1f),
)
IconButton(onClick = onDecrement, enabled = enabled && attempts > 1) {
Icon(Icons.Rounded.Remove, contentDescription = stringResource(R.string.health_check_attempt_minus))
Icon(ShxIcons.Minus, contentDescription = stringResource(R.string.health_check_attempt_minus))
}
Text(
text = attempts.toString(),
@@ -349,7 +341,7 @@ private fun AttemptsRow(
modifier = Modifier.padding(horizontal = ShxSpacing.sm),
)
IconButton(onClick = onIncrement, enabled = enabled && attempts < 5) {
Icon(Icons.Rounded.Add, contentDescription = stringResource(R.string.health_check_attempt_plus))
Icon(ShxIcons.Plus, contentDescription = stringResource(R.string.health_check_attempt_plus))
}
}
}
@@ -52,25 +52,6 @@ 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 org.strongswan.android.ui.compose.design.ShxAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -126,6 +107,15 @@ import org.strongswan.android.ui.compose.home.certExpiry
import org.strongswan.android.ui.compose.home.snoozeCertExpiry
import org.strongswan.android.ui.compose.home.connectProgress
import org.strongswan.android.R
import androidx.compose.ui.draw.clip
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxSheetTitle
import org.strongswan.android.ui.compose.design.ShxSheet
import org.strongswan.android.ui.compose.design.ShxIcons
import androidx.compose.foundation.layout.widthIn
import org.strongswan.android.logic.routing.RuIpListManager
import org.strongswan.android.ui.DiagnosticsActivity
@@ -143,7 +133,6 @@ 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
@@ -229,8 +218,6 @@ fun HomeScreen(
}
}
}
var pendingDelete by remember { mutableStateOf<VpnProfile?>(null) }
var sheetProfile by remember { mutableStateOf<VpnProfile?>(null) }
var pickerOpen by remember { mutableStateOf(false) }
var pauseSheetOpen by remember { mutableStateOf(false) }
val networkKinds by viewModel.networkKinds.collectAsStateWithLifecycle()
@@ -385,7 +372,7 @@ fun HomeScreen(
},
onLongPick = { profile ->
pickerOpen = false
sheetProfile = profile
context.startActivity(org.strongswan.android.ui.ServerActivity.intent(context, profile.getUUID().toString()))
},
onManage = {
pickerOpen = false
@@ -417,69 +404,10 @@ fun HomeScreen(
)
}
if (state.detailsOpen)
{
ConnectionDetailsSheet(
profile = state.activeProfile ?: state.selected,
traffic = traffic,
durationSec = durationSec,
probes = probes,
exit = exit,
onDismiss = { viewModel.openDetails(false) },
onDisconnect = {
viewModel.openDetails(false)
viewModel.disconnect()
},
)
}
sheetProfile?.let { profile ->
ProfileActionsSheet(
profile = profile,
active = connected && profile.getUUID() == state.activeProfile?.getUUID(),
onDismiss = { sheetProfile = null },
onConnect = {
sheetProfile = null
viewModel.select(profile)
viewModel.connect(context, profile)
},
onEdit = {
sheetProfile = null
onEditProfile(profile)
},
onShare = {
sheetProfile = null
onShareProfile(profile)
},
onDelete = {
sheetProfile = null
pendingDelete = profile
},
)
}
pendingDelete?.let { profile ->
ShxAlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text(stringResource(R.string.delete_profile)) },
text = { Text(profile.name ?: "") },
confirmButton = {
TextButton(onClick = {
viewModel.delete(profile)
pendingDelete = null
}) { Text(stringResource(R.string.delete_profile)) }
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
}
/** Bottom sheet to switch the profile without leaving the Connect tab. */
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
/** «Server» sheet of Home: the servers as on F_Servers, manage / add below. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ProfilePickerSheet(
profiles: List<VpnProfile>,
@@ -492,240 +420,58 @@ private fun ProfilePickerSheet(
onAdd: () -> Unit,
)
{
val c = LocalShxColors.current
/* 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,
)
ShxSheet(onDismiss = onDismiss)
{
Column(Modifier.fillMaxWidth())
ShxSheetTitle(stringResource(R.string.home_profile_picker_title))
ShxPanel()
{
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,
val active = profile.getUUID() == activeUuid
val chosen = profile.getUUID() == selected?.getUUID()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().height(68.dp)
.combinedClickable(
onClick = { onPick(profile) },
onLongClick = { onLongPick(profile) },
onLongClickLabel = stringResource(R.string.profile_actions),
)
.padding(horizontal = 14.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(CircleShape).background(if (active) c.accent else c.tile))
{
Text(
org.strongswan.android.ui.compose.home.serverCode(profile, null),
style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = if (active) Color.White else c.onAccentContainer,
)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp))
{
Text(profile.name ?: profile.gateway ?: "", style = ShxType.rowTitle, color = c.text, maxLines = 1)
Text(profile.gateway ?: "", style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, maxLines = 1)
}
if (chosen)
{
Icon(ShxIcons.Check, contentDescription = null, tint = c.accent, modifier = Modifier.size(20.dp))
}
}
if (index < ordered.lastIndex)
{
ShxDivider()
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
}
}
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))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ConnectionDetailsSheet(
profile: VpnProfile?,
traffic: TrafficUi,
durationSec: Long,
probes: Map<ProbeTarget, ProbeResult>,
exit: ExitUi?,
onDismiss: () -> Unit,
onDisconnect: () -> Unit,
)
{
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
)
{
Column(Modifier.padding(horizontal = 24.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp))
{
ProfileAvatar(icon = Icons.Rounded.Language, connected = true)
Column {
Text(
profile?.name ?: "",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
profile?.gateway ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.height(16.dp))
DetailRow(icon = Icons.Rounded.Language, label = stringResource(R.string.profile_gateway_label), value = profile?.gateway ?: "—")
DetailRow(icon = Icons.Rounded.Schedule, label = stringResource(R.string.duration_label), value = formatDuration(durationSec))
DetailRow(
icon = Icons.Rounded.ArrowDownward,
label = stringResource(R.string.traffic_down),
value = "${formatBytes(traffic.bytesIn)} · ${formatRate(traffic.rateIn)}",
)
DetailRow(
icon = Icons.Rounded.ArrowUpward,
label = stringResource(R.string.traffic_up),
value = "${formatBytes(traffic.bytesOut)} · ${formatRate(traffic.rateOut)}",
)
/* RU-split session breakdown; null in the system-routes mode where
* the engine never sees the direct traffic */
traffic.split?.let { split ->
DetailRow(
icon = Icons.Rounded.AltRoute,
label = stringResource(R.string.traffic_split_tunnel),
value = "${formatBytes(split.tunnelBytes)} · ${formatRate(split.tunnelRate)}",
)
DetailRow(
icon = Icons.Rounded.AltRoute,
label = stringResource(R.string.traffic_split_direct),
value = "${formatBytes(split.directBytes)} · ${formatRate(split.directRate)}",
)
}
exit?.let { e ->
e.ip?.let { ip ->
DetailRow(
icon = Icons.Rounded.Public,
label = stringResource(R.string.exit_ip_label),
value = e.country?.let { "$ip · $it" } ?: ip,
)
}
e.network?.let { network ->
DetailRow(
icon = if (e.ssid != null) Icons.Rounded.Wifi else Icons.Rounded.SignalCellularAlt,
label = stringResource(R.string.network_label),
value = e.ssid?.let { "$network · $it" } ?: network,
)
}
}
if (probes.isNotEmpty())
{
probes.sortedForDisplay().forEach { result ->
DetailRow(
icon = Icons.Rounded.Dns,
label = stringResource(result.target.labelRes()),
value = result.rttText(),
)
}
}
Spacer(Modifier.height(24.dp))
FilledTonalButton(
onClick = onDisconnect,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.disconnect))
}
Spacer(Modifier.height(32.dp))
ShxButton(stringResource(R.string.home_manage_profiles), onManage, style = ShxButtonStyle.SOFT, height = 48.dp, modifier = Modifier.weight(1f))
ShxButton(stringResource(R.string.add_profile_cta), onAdd, style = ShxButtonStyle.OUTLINE, height = 48.dp, modifier = Modifier.weight(1f), icon = ShxIcons.Plus)
}
}
}
@Composable
private fun DetailRow(icon: ImageVector, label: String, value: String)
{
ListItem(
leadingContent = {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier
.padding(8.dp)
.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
headlineContent = { Text(label) },
supportingContent = { Text(value) },
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileActionsSheet(
profile: VpnProfile,
active: Boolean,
onDismiss: () -> Unit,
onConnect: () -> Unit,
onEdit: () -> Unit,
onShare: () -> Unit,
onDelete: () -> Unit,
)
{
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
)
{
Column(Modifier.fillMaxWidth())
{
ShxProfileRow(profile = profile, active = active, onClick = {}, onLongClick = {})
ShxDivider(indent = false)
SheetAction(icon = Icons.Rounded.PlayArrow, label = stringResource(R.string.connect), onClick = onConnect)
ShxDivider(indent = false)
SheetAction(icon = Icons.Rounded.Edit, label = stringResource(R.string.edit_profile), onClick = onEdit)
ShxDivider(indent = false)
SheetAction(icon = Icons.Rounded.Share, label = stringResource(R.string.share_profile), onClick = onShare)
ShxDivider(indent = false)
SheetAction(
icon = Icons.Rounded.Delete,
label = stringResource(R.string.delete_profile),
tint = MaterialTheme.colorScheme.error,
onClick = onDelete,
)
Spacer(Modifier.height(24.dp))
}
}
}
@Composable
private fun SheetAction(icon: ImageVector, label: String, onClick: () -> Unit, tint: Color = Color.Unspecified)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick)
.padding(horizontal = 24.dp, vertical = 16.dp),
)
{
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp))
Spacer(Modifier.size(16.dp))
Text(label, style = MaterialTheme.typography.bodyLarge)
}
}
@@ -39,12 +39,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.rounded.AltRoute
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenuItem
@@ -410,7 +404,7 @@ fun ProfileFormScreen(
trailing = {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(CircleShape).clickable { showPassword = !showPassword })
{
Icon(if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, null, tint = c.textSub, modifier = Modifier.size(20.dp))
Icon(if (showPassword) ShxIcons.EyeOff else ShxIcons.Eye, null, tint = c.textSub, modifier = Modifier.size(20.dp))
}
},
)
@@ -1,135 +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
import org.strongswan.android.ui.compose.components.ShxConnectedPill
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.Language
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.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
@Composable
fun ProfileAvatar(icon: ImageVector, connected: Boolean, modifier: Modifier = Modifier)
{
val status = LocalShxStatusColors.current
val container = if (connected) status.connectedContainer else MaterialTheme.colorScheme.primaryContainer
val tint = if (connected) status.connected else MaterialTheme.colorScheme.primary
Box(
modifier = modifier
.size(40.dp)
.background(container, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size(22.dp),
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ShxProfileRow(
profile: VpnProfile,
active: Boolean,
leadingIcon: ImageVector = Icons.Rounded.Language,
onClick: () -> Unit,
onLongClick: () -> Unit = {},
onLongClickLabel: String? = null,
trailing: (@Composable () -> Unit)? = null,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
onLongClickLabel = onLongClickLabel,
)
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
) {
ProfileAvatar(icon = leadingIcon, connected = active)
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = ShxSpacing.md),
) {
Text(
text = profile.name ?: "",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = profile.gateway ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (trailing != null)
{
trailing()
}
else if (active)
{
ActiveBadge()
}
}
}
@Composable
fun ActiveBadge(modifier: Modifier = Modifier)
{
ShxConnectedPill(text = stringResource(R.string.badge_connected), modifier = modifier)
}
@Composable
fun ShxDivider(modifier: Modifier = Modifier, indent: Boolean = true)
{
Surface(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
modifier = modifier
.fillMaxWidth()
.padding(start = if (indent) 68.dp else 0.dp, end = if (indent) ShxSpacing.lg else 0.dp)
.height(1.dp),
) {}
}
@@ -17,18 +17,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.SearchOff
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material.icons.rounded.Stop
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -172,7 +160,7 @@ fun ProfilesScreen(
{
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center)
{
ShxEmptyState(icon = Icons.Rounded.Language, message = stringResource(R.string.profile_detail_placeholder))
ShxEmptyState(icon = ShxIcons.Globe, message = stringResource(R.string.profile_detail_placeholder))
}
}
}
@@ -425,7 +413,7 @@ private fun ServerRow(profile: VpnProfile, active: Boolean, ping: ServerPing?, o
modifier = Modifier.size(44.dp).clip(CircleShape).clickable(role = Role.Button, onClick = onMore),
)
{
Icon(Icons.Rounded.MoreVert, contentDescription = stringResource(R.string.action_more_options), tint = c.textSub, modifier = Modifier.size(20.dp))
Icon(ShxIcons.More, contentDescription = stringResource(R.string.action_more_options), tint = c.textSub, modifier = Modifier.size(20.dp))
}
}
}
@@ -107,11 +107,8 @@ import org.strongswan.android.ui.compose.components.PredictiveBackScale
import org.strongswan.android.ui.compose.components.ShxCard
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
import org.strongswan.android.ui.compose.routing.DeadHostsPage
import org.strongswan.android.ui.compose.routing.DirectAppsCard
import org.strongswan.android.ui.compose.routing.DirectAppsScreen
import org.strongswan.android.ui.compose.routing.RecentAppsPage
import org.strongswan.android.ui.compose.routing.RoutingHeroCard
import org.strongswan.android.ui.compose.routing.RuSitesCard
import org.strongswan.android.ui.compose.routing.RulesPage
import org.strongswan.android.ui.compose.routing.routingSummary
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
@@ -24,32 +24,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Autorenew
import androidx.compose.material.icons.rounded.BatteryChargingFull
import androidx.compose.material.icons.rounded.Bedtime
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.BugReport
import androidx.compose.material.icons.rounded.Cached
import androidx.compose.material.icons.rounded.CloudDownload
import androidx.compose.material.icons.rounded.CloudSync
import androidx.compose.material.icons.rounded.Description
import androidx.compose.material.icons.rounded.FileDownload
import androidx.compose.material.icons.rounded.GppGood
import androidx.compose.material.icons.rounded.GppMaybe
import androidx.compose.material.icons.rounded.History
import androidx.compose.material.icons.rounded.NetworkCheck
import androidx.compose.material.icons.rounded.Palette
import androidx.compose.material.icons.rounded.RestartAlt
import androidx.compose.material.icons.rounded.Security
import androidx.compose.material.icons.rounded.SystemUpdateAlt
import androidx.compose.material.icons.rounded.VerifiedUser
import androidx.compose.material.icons.rounded.Wifi
import org.strongswan.android.ui.compose.design.ShxAlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -77,7 +55,6 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Brightness6
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
@@ -101,6 +78,7 @@ import androidx.compose.runtime.produceState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.ui.compose.design.ShxDialogButton
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.background
@@ -284,14 +262,14 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
ShxSectionDivider()
}
ShxSettingRow(
icon = Icons.Rounded.History,
icon = ShxIcons.Clock,
title = stringResource(R.string.pref_default_vpn_profile),
subtitle = defaultLabel,
onClick = { showDefaultPicker = true },
)
ShxSectionDivider()
ShxSettingToggleRow(
icon = Icons.Rounded.BatteryChargingFull,
icon = ShxIcons.Battery,
title = stringResource(R.string.pref_power_whitelist_title),
subtitle = stringResource(R.string.pref_power_whitelist_summary),
checked = ignorePower,
@@ -306,7 +284,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
* disabled, which breaks watchdog-based recovery of the tunnel */
ShxSectionDivider()
ShxSettingRow(
icon = Icons.Rounded.Bedtime,
icon = ShxIcons.Moon,
title = stringResource(R.string.samsung_sleep_title),
subtitle = stringResource(R.string.samsung_sleep_summary),
onClick = { openSamsungBatterySettings(context) },
@@ -323,7 +301,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
mutableStateOf(WhiteListManager.getInstance(context).currentConfig.enabled)
}
ShxSettingToggleRow(
icon = Icons.Rounded.GppMaybe,
icon = ShxIcons.Alert,
title = stringResource(R.string.whitelist_analysis_title),
subtitle = stringResource(R.string.whitelist_analysis_summary),
checked = whiteListEnabled,
@@ -337,7 +315,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
mutableStateOf(VpnShortcutManager.getInstance(context).isEnabled())
}
ShxSettingToggleRow(
icon = Icons.Rounded.Bolt,
icon = ShxIcons.Bolt,
title = stringResource(R.string.pref_routines_shortcuts_title),
subtitle = stringResource(R.string.pref_routines_shortcuts_summary),
checked = shortcutsEnabled,
@@ -351,7 +329,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
mutableStateOf(prefs.getBoolean(Constants.PREF_WATCHDOG_ENABLED, true))
}
ShxSettingToggleRow(
icon = Icons.Rounded.RestartAlt,
icon = ShxIcons.Refresh,
title = stringResource(R.string.watchdog_title),
subtitle = stringResource(R.string.watchdog_summary),
checked = watchdogEnabled,
@@ -377,7 +355,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
mutableStateOf(prefs.getBoolean(Constants.PREF_SAFE_IPV6, false))
}
ShxSettingToggleRow(
icon = Icons.Rounded.GppGood,
icon = ShxIcons.Shield,
title = stringResource(R.string.safe_ipv6_title),
subtitle = stringResource(R.string.safe_ipv6_summary),
checked = safeIpv6,
@@ -394,7 +372,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
ShxSection(title = stringResource(R.string.settings_section_certs))
{
ShxSettingRow(
icon = Icons.Rounded.VerifiedUser,
icon = ShxIcons.Cert,
title = stringResource(R.string.trusted_certs_title),
onClick = {
context.startActivity(Intent(context, TrustedCertificatesActivity::class.java))
@@ -402,7 +380,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
)
ShxSectionDivider()
ShxSettingRow(
icon = Icons.Rounded.Cached,
icon = ShxIcons.Refresh,
title = stringResource(R.string.crl_cache),
onClick = { showCrl = true },
)
@@ -428,7 +406,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
mutableStateOf(prefs.getBoolean(Constants.PREF_EXTENDED_DIAG, false))
}
ShxSettingToggleRow(
icon = Icons.Rounded.BugReport,
icon = ShxIcons.Bug,
title = stringResource(R.string.extended_diag_title),
subtitle = stringResource(R.string.extended_diag_summary),
checked = extendedDiag,
@@ -476,7 +454,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
mutableStateOf(prefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true))
}
ShxSettingToggleRow(
icon = Icons.Rounded.Autorenew,
icon = ShxIcons.Refresh,
title = stringResource(R.string.update_auto_check_title),
subtitle = stringResource(R.string.update_auto_check_summary),
checked = autoUpdate,
@@ -490,7 +468,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
mutableStateOf(prefs.getBoolean(Constants.PREF_SILENT_UPDATES, false))
}
ShxSettingToggleRow(
icon = Icons.Rounded.CloudSync,
icon = ShxIcons.Refresh,
title = stringResource(R.string.update_silent_title),
subtitle = stringResource(R.string.update_silent_summary),
checked = silentUpdates,
@@ -501,7 +479,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
)
ShxSectionDivider()
ShxSettingRow(
icon = Icons.Rounded.SystemUpdateAlt,
icon = ShxIcons.Download,
title = stringResource(R.string.update_check_title),
subtitle = updateStatusText(updateState),
onClick = {
@@ -518,7 +496,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
{
ShxSectionDivider()
ShxSettingRow(
icon = Icons.Rounded.CloudDownload,
icon = ShxIcons.Download,
title = stringResource(R.string.update_download_install),
subtitle = when (val state = updateState)
{
@@ -583,7 +561,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
}
},
confirmButton = {
TextButton(onClick = { showDefaultPicker = false }) {
ShxDialogButton(onClick = { showDefaultPicker = false }) {
Text(stringResource(android.R.string.cancel))
}
},
@@ -603,7 +581,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
)
},
confirmButton = {
TextButton(onClick = {
ShxDialogButton(primary = true, danger = true, onClick = {
if (files.isEmpty())
{
scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.clear_crl_cache_msg_none)) }
@@ -616,7 +594,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
}) { Text(stringResource(R.string.clear)) }
},
dismissButton = {
TextButton(onClick = { showCrl = false }) {
ShxDialogButton(onClick = { showCrl = false }) {
Text(stringResource(android.R.string.cancel))
}
},
@@ -632,13 +610,13 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
title = { Text(stringResource(R.string.update_confirm_title, info.versionName)) },
text = { if (notes.isNotEmpty()) { Text(notes) } },
confirmButton = {
TextButton(onClick = {
ShxDialogButton(primary = true, onClick = {
showUpdateConfirm = false
startUpdate()
}) { Text(stringResource(R.string.update_action)) }
},
dismissButton = {
TextButton(onClick = { showUpdateConfirm = false }) {
ShxDialogButton(onClick = { showUpdateConfirm = false }) {
Text(stringResource(android.R.string.cancel))
}
},
@@ -930,13 +908,13 @@ private fun SettingsRoot(onOpen: (SettingsPage) -> Unit, updateState: UpdateStat
title = { Text(stringResource(R.string.kill_switch_dialog_title)) },
text = { Text(stringResource(R.string.kill_switch_dialog_text)) },
confirmButton = {
TextButton(onClick = {
ShxDialogButton(primary = true, onClick = {
showKillSwitchDialog = false
context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
}) { Text(stringResource(R.string.kill_switch_dialog_enable)) }
},
dismissButton = {
TextButton(onClick = { showKillSwitchDialog = false }) {
ShxDialogButton(onClick = { showKillSwitchDialog = false }) {
Text(stringResource(android.R.string.cancel))
}
},
@@ -999,20 +977,20 @@ private fun KillSwitchRow()
title = { Text(stringResource(R.string.kill_switch_dialog_title)) },
text = { Text(stringResource(R.string.kill_switch_dialog_text)) },
confirmButton = {
TextButton(onClick = {
ShxDialogButton(primary = true, onClick = {
showKillSwitchDialog = false
context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
}) { Text(stringResource(R.string.kill_switch_dialog_enable)) }
},
dismissButton = {
TextButton(onClick = { showKillSwitchDialog = false }) {
ShxDialogButton(onClick = { showKillSwitchDialog = false }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
ShxSettingRow(
icon = Icons.Rounded.Security,
icon = ShxIcons.Shield,
title = stringResource(R.string.kill_switch_title),
subtitle = stringResource(
if (lockdownActive) R.string.kill_switch_summary_on
@@ -33,11 +33,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.Wifi
import org.strongswan.android.ui.compose.design.ShxAlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -67,6 +62,7 @@ import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.foundation.layout.Arrangement
import org.strongswan.android.R
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.components.ShxSection
import org.strongswan.android.ui.compose.components.ShxSectionDivider
import org.strongswan.android.ui.compose.components.ShxSettingRow
@@ -169,7 +165,7 @@ fun TrustedNetworksScreen(
ShxSection(title = stringResource(R.string.settings_section_general))
{
ShxSettingToggleRow(
icon = Icons.Rounded.Wifi,
icon = ShxIcons.Wifi,
title = stringResource(R.string.auto_connect_title),
subtitle = stringResource(R.string.auto_connect_summary),
checked = enabled,
@@ -181,7 +177,7 @@ fun TrustedNetworksScreen(
)
ShxSectionDivider()
ShxSettingRow(
icon = Icons.Rounded.Language,
icon = ShxIcons.Globe,
title = stringResource(R.string.auto_connect_profile),
subtitle = selectedProfile?.name
?: stringResource(R.string.auto_connect_profile_none),
@@ -209,7 +205,7 @@ fun TrustedNetworksScreen(
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md))
{
Icon(Icons.Rounded.Wifi, contentDescription = null)
Icon(ShxIcons.Wifi, contentDescription = null)
Text(
text = stringResource(R.string.auto_connect_location_title),
style = MaterialTheme.typography.titleSmall,
@@ -269,7 +265,7 @@ fun TrustedNetworksScreen(
)
{
Icon(
Icons.Rounded.Wifi,
ShxIcons.Wifi,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
@@ -283,7 +279,7 @@ fun TrustedNetworksScreen(
)
IconButton(onClick = { pendingDeleteSsid = ssid }) {
Icon(
Icons.Rounded.Delete,
ShxIcons.Trash,
contentDescription = stringResource(R.string.delete_profile),
tint = MaterialTheme.colorScheme.error,
)
@@ -410,7 +406,7 @@ private fun TrustedAddNetworkRow(
{
TextButton(onClick = { onAddCurrent(currentSsid) })
{
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Icon(ShxIcons.Plus, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(ShxSpacing.sm))
Text(stringResource(R.string.auto_connect_add_current, currentSsid))
}
@@ -419,7 +415,7 @@ private fun TrustedAddNetworkRow(
{
TextButton(onClick = onAddManual)
{
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Icon(ShxIcons.Plus, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(ShxSpacing.sm))
Text(stringResource(R.string.auto_connect_add_network))
}
@@ -19,9 +19,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Visibility
import androidx.compose.material.icons.rounded.VisibilityOff
import org.strongswan.android.ui.compose.design.ShxAlertDialog
import org.strongswan.android.ui.compose.design.ShxFormField
import androidx.compose.material3.Icon
@@ -29,7 +26,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -62,6 +58,7 @@ import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.R
import org.strongswan.android.ui.compose.design.ShxDialogButton
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.ui.compose.theme.ShxTheme
@@ -182,7 +179,7 @@ private fun ConfirmationUi(dialog: VpnDialog.Confirmation)
Row(horizontalArrangement = Arrangement.spacedBy(ShxSpacing.xs)) {
if (dialog.reconnect)
{
TextButton(onClick = {
ShxDialogButton(onClick = {
VpnDialogBus.dismiss()
VpnDialogBus.listener?.onVpnDialogDisconnect()
}) {
@@ -192,7 +189,7 @@ private fun ConfirmationUi(dialog: VpnDialog.Confirmation)
)
}
}
TextButton(onClick = {
ShxDialogButton(primary = true, onClick = {
VpnDialogBus.dismiss()
val listener = VpnDialogBus.listener
if (dialog.disconnect)
@@ -207,7 +204,7 @@ private fun ConfirmationUi(dialog: VpnDialog.Confirmation)
}
},
dismissButton = {
TextButton(onClick = ::cancelDialog) {
ShxDialogButton(onClick = ::cancelDialog) {
Text(stringResource(android.R.string.cancel))
}
},
@@ -245,7 +242,7 @@ private fun LoginUi(dialog: VpnDialog.Login)
trailing = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
if (showPassword) Icons.Rounded.Visibility else Icons.Rounded.VisibilityOff,
if (showPassword) ShxIcons.Eye else ShxIcons.EyeOff,
contentDescription = stringResource(
if (showPassword) R.string.login_hide_password else R.string.login_show_password
),
@@ -256,13 +253,13 @@ private fun LoginUi(dialog: VpnDialog.Login)
}
},
confirmButton = {
TextButton(onClick = {
ShxDialogButton(primary = true, onClick = {
VpnDialogBus.dismiss()
VpnDialogBus.listener?.onVpnDialogLogin(dialog.profileInfo, password)
}) { Text(stringResource(R.string.login_confirm)) }
},
dismissButton = {
TextButton(onClick = ::cancelDialog) {
ShxDialogButton(onClick = ::cancelDialog) {
Text(stringResource(android.R.string.cancel))
}
},
@@ -277,13 +274,13 @@ private fun CertificateRequiredUi(dialog: VpnDialog.CertificateRequired)
title = { Text(stringResource(R.string.certificate_required_title)) },
text = { Text(stringResource(R.string.certificate_required_text)) },
confirmButton = {
TextButton(onClick = {
ShxDialogButton(primary = true, onClick = {
VpnDialogBus.dismiss()
VpnDialogBus.listener?.onVpnDialogEditProfile(dialog.uuid)
}) { Text(stringResource(R.string.edit_profile)) }
},
dismissButton = {
TextButton(onClick = ::cancelDialog) {
ShxDialogButton(onClick = ::cancelDialog) {
Text(stringResource(android.R.string.cancel))
}
},
@@ -298,7 +295,7 @@ private fun PowerWhitelistUi()
title = { Text(stringResource(R.string.power_whitelist_title)) },
text = { Text(stringResource(R.string.power_whitelist_text)) },
confirmButton = {
TextButton(onClick = {
ShxDialogButton(primary = true, onClick = {
VpnDialogBus.dismiss()
VpnDialogBus.listener?.onVpnDialogPowerWhitelist()
}) { Text(stringResource(android.R.string.ok)) }
@@ -314,7 +311,7 @@ private fun NotSupportedUi(dialog: VpnDialog.NotSupported)
title = { Text(stringResource(R.string.vpn_not_supported_title)) },
text = { Text(stringResource(dialog.message)) },
confirmButton = {
TextButton(onClick = ::cancelDialog) {
ShxDialogButton(primary = true, onClick = ::cancelDialog) {
Text(stringResource(android.R.string.ok))
}
},
@@ -1,62 +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.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** Small rounded label with explicit colors, e.g. "Connected". */
@Composable
fun ShxStatusPill(
text: String,
containerColor: Color,
contentColor: Color,
modifier: Modifier = Modifier,
shape: Shape = CircleShape,
style: TextStyle = MaterialTheme.typography.labelSmall,
)
{
Surface(
color = containerColor,
contentColor = contentColor,
shape = shape,
modifier = modifier,
) {
Text(
text = text,
style = style,
modifier = Modifier.padding(horizontal = 10.dp, vertical = ShxSpacing.xs),
)
}
}
/** Pill in the "connected" status palette (independent of dynamic color). */
@Composable
fun ShxConnectedPill(text: String, modifier: Modifier = Modifier)
{
val status = LocalShxStatusColors.current
ShxStatusPill(
text = text,
containerColor = status.connectedContainer,
contentColor = status.connected,
modifier = modifier,
)
}
@@ -73,4 +73,14 @@ object ShxIcons
val Pin = line("pin", "M12 21s-7-6.1-7-11a7 7 0 0 1 14 0c0 4.9-7 11-7 11zM12 12.5a2.5 2.5 0 1 0 0-5a2.5 2.5 0 0 0 0 5", 2f)
val Stats = line("stats", "M5 20V10M12 20V4M19 20v-7", 2.4f)
val Clock = line("clock", "M12 7v5l3 2M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18", 2.2f)
val Eye = line("eye", "M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12zM12 9a3 3 0 1 0 0 6a3 3 0 1 0 0-6", 2f)
val EyeOff = line("eyeOff", "M3 3l18 18M10.6 5.1A10 10 0 0 1 12 5c6.4 0 10 7 10 7a17 17 0 0 1-3.2 4.2M6.6 6.6C3.9 8.4 2 12 2 12s3.6 7 10 7a9.6 9.6 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2", 2f)
val More = line("more", "M12 5h.01M12 12h.01M12 19h.01", 3f)
val Battery = line("battery", "M3 8h15v8H3zM21 11v2M6 11v2", 2f)
val Bug = line("bug", "M9 9h6v8a3 3 0 0 1-6 0zM9 13H5M19 13h-4M8 5l2 2M16 5l-2 2M10 7h4", 2f)
val Edit = line("edit", "M4 20h4l10-10-4-4L4 16zM13 7l4 4", 2f)
val ArrowUp = line("arrowUp", "M12 19V5M6 11l6-6 6 6", 2.2f)
val ArrowDown = line("arrowDown", "M12 5v14M6 13l6 6 6-6", 2.2f)
val Minus = line("minus", "M5 12h14", 2.4f)
val Share = line("share", "M18 8a3 3 0 1 0 0-6a3 3 0 0 0 0 6zM6 15a3 3 0 1 0 0-6a3 3 0 0 0 0 6zM18 22a3 3 0 1 0 0-6a3 3 0 0 0 0 6zM8.6 13.5l6.8 4M15.4 6.5l-6.8 4", 2f)
}
@@ -39,9 +39,6 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
@@ -107,7 +104,7 @@ fun ShxPageHeader(title: String, onBack: () -> Unit, modifier: Modifier = Modifi
modifier = Modifier.size(44.dp).clip(CircleShape).clickable(role = Role.Button, onClick = onBack),
)
{
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.back), tint = c.text, modifier = Modifier.size(22.dp))
Icon(ShxIcons.Back, contentDescription = stringResource(R.string.back), tint = c.text, modifier = Modifier.size(22.dp))
}
Text(
title,
@@ -259,7 +256,7 @@ fun ShxRow(
trailing?.invoke(this)
if (chevron)
{
Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null, tint = c.textSub, modifier = Modifier.size(20.dp))
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(20.dp))
}
}
if (divider)
@@ -875,3 +872,49 @@ private fun DialogScrim()
}
}
}
/**
* A dialog button of the mockup: the action as a filled 44dp pill (red when
* [danger]), «Cancel» and the like as accent text. Same shape of call as
* the Material TextButton it replaces.
*/
@Composable
fun ShxDialogButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
primary: Boolean = false,
danger: Boolean = false,
content: @Composable RowScope.() -> Unit,
)
{
val c = LocalShxColors.current
val bg = when
{
!primary -> Color.Transparent
danger -> c.error
else -> c.accent
}
val fg = when
{
!primary -> if (danger) c.error else c.accentText
danger -> c.background
else -> Color.White
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = modifier.height(44.dp).clip(RoundedCornerShape(22.dp)).background(if (enabled) bg else bg.copy(alpha = 0.4f))
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
.padding(horizontal = if (primary) 20.dp else 16.dp),
)
{
androidx.compose.runtime.CompositionLocalProvider(androidx.compose.material3.LocalContentColor provides if (enabled) fg else fg.copy(alpha = 0.5f))
{
androidx.compose.material3.ProvideTextStyle(if (primary) ShxType.button else ShxType.button.copy(fontWeight = FontWeight.Bold))
{
content()
}
}
}
}
@@ -1,185 +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. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui.compose.routing
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Apps
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.draw.clip
import androidx.compose.ui.res.pluralStringResource
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.components.ShxCard
import org.strongswan.android.ui.compose.components.ShxIconBadge
import org.strongswan.android.ui.compose.theme.ShxSpacing
private const val STACK_ICONS = 3
/**
* "Apps direct" entry card: counter, a stack of real app icons and a chevron
* into the full list. While the list is empty it offers the one-tap preset
* for Russian apps instead.
*
* @param previewPackages a few direct packages for the icon stack (the rest is a "+N" bubble)
*/
@Composable
internal fun DirectAppsCard(
count: Int,
previewPackages: List<String>,
onOpen: () -> Unit,
onAddAllRf: () -> Unit,
modifier: Modifier = Modifier,
)
{
ShxCard(modifier = modifier.fillMaxWidth())
{
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpen)
.heightIn(min = 72.dp)
.padding(ShxSpacing.lg),
)
{
ShxIconBadge(Icons.Rounded.Apps)
Column(
Modifier
.weight(1f)
.padding(horizontal = ShxSpacing.lg),
)
{
Text(
text = stringResource(R.string.routing_apps_direct_title),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
text = if (count > 0)
{
pluralStringResource(R.plurals.routing_apps_direct_summary, count, count)
}
else
{
stringResource(R.string.routing_apps_direct_empty)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (count > 0)
{
IconStack(previewPackages.take(STACK_ICONS), extra = count - previewPackages.take(STACK_ICONS).size)
Spacer(Modifier.width(ShxSpacing.sm))
}
Icon(
Icons.AutoMirrored.Rounded.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (count == 0)
{
FlowRow(
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
modifier = Modifier.padding(start = 72.dp, end = ShxSpacing.lg, bottom = ShxSpacing.lg),
)
{
Button(onClick = onAddAllRf)
{
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.routing_apps_add_all_rf))
}
OutlinedButton(onClick = onOpen)
{
Text(stringResource(R.string.routing_apps_pick_manually))
}
}
}
}
}
}
/** Overlapping app icons with a "+N" bubble for the rest. */
@Composable
private fun IconStack(packages: List<String>, extra: Int)
{
val ring = MaterialTheme.colorScheme.surfaceContainerLow
Row(verticalAlignment = Alignment.CenterVertically)
{
packages.forEachIndexed { index, pkg ->
Box(
Modifier
.offset(x = (-8 * index).dp)
.size(28.dp)
.border(BorderStroke(2.dp, ring), CircleShape)
.padding(2.dp)
.clip(CircleShape),
)
{
AppIcon(pkg, 24.dp)
}
}
if (extra > 0)
{
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.offset(x = (-8 * packages.size).dp)
.heightIn(min = 28.dp),
)
{
Text(
text = "+$extra",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 5.dp),
)
}
}
}
}
@@ -1,128 +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. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui.compose.routing
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.AltRoute
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
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.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** Plain-words summary of what currently bypasses the VPN. */
@Composable
internal fun routingSummary(enabled: Boolean, ruEnabled: Boolean, appCount: Int, ruleCount: Int = 0): String
{
if (!enabled)
{
return stringResource(R.string.routing_hero_off_summary)
}
val parts = ArrayList<String>()
if (ruEnabled)
{
parts.add(stringResource(R.string.routing_hero_part_sites))
}
if (appCount > 0)
{
parts.add(pluralStringResource(R.plurals.routing_apps_n, appCount, appCount))
}
if (ruleCount > 0)
{
parts.add(pluralStringResource(R.plurals.routing_rules_n, ruleCount, ruleCount))
}
return if (parts.isEmpty())
{
stringResource(R.string.routing_hero_nothing)
}
else
{
stringResource(R.string.routing_hero_summary, parts.joinToString(" · "))
}
}
/** Hero card: the master switch plus a one-line summary of what bypasses the VPN. */
@Composable
internal fun RoutingHeroCard(
enabled: Boolean,
summary: String,
onToggle: (Boolean) -> Unit,
modifier: Modifier = Modifier,
)
{
Surface(
shape = MaterialTheme.shapes.large,
color = if (enabled) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerLow,
contentColor = if (enabled) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
border = if (enabled) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = modifier.fillMaxWidth(),
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.toggleable(value = enabled, role = Role.Switch, onValueChange = onToggle)
.padding(start = ShxSpacing.lg, top = 14.dp, bottom = 14.dp, end = ShxSpacing.lg),
)
{
Icon(
Icons.Rounded.AltRoute,
contentDescription = null,
tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(ShxSpacing.lg))
Column(Modifier.weight(1f))
{
Text(
text = stringResource(R.string.routing_hero_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = summary,
style = MaterialTheme.typography.bodyMedium,
color = if (enabled)
{
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f)
}
else
{
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
Spacer(Modifier.width(ShxSpacing.md))
Switch(checked = enabled, onCheckedChange = null)
}
}
}
@@ -307,3 +307,33 @@ internal fun RecentAppsCard(apps: List<RecentPreview>, failing: Int, onClick: ()
}
}
/** Plain-words summary of what currently bypasses the VPN. */
@Composable
internal fun routingSummary(enabled: Boolean, ruEnabled: Boolean, appCount: Int, ruleCount: Int = 0): String
{
if (!enabled)
{
return stringResource(R.string.routing_hero_off_summary)
}
val parts = ArrayList<String>()
if (ruEnabled)
{
parts.add(stringResource(R.string.routing_hero_part_sites))
}
if (appCount > 0)
{
parts.add(pluralStringResource(R.plurals.routing_apps_n, appCount, appCount))
}
if (ruleCount > 0)
{
parts.add(pluralStringResource(R.plurals.routing_rules_n, ruleCount, ruleCount))
}
return if (parts.isEmpty())
{
stringResource(R.string.routing_hero_nothing)
}
else
{
stringResource(R.string.routing_hero_summary, parts.joinToString(" · "))
}
}
@@ -1,288 +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. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui.compose.routing
import android.text.format.DateUtils
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
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.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Public
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
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.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.logic.routing.RuIpListState
import org.strongswan.android.logic.routing.RuIpListStatus
import org.strongswan.android.ui.compose.components.ShxCard
import org.strongswan.android.ui.compose.components.ShxIconBadge
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.utils.Constants
import java.text.NumberFormat
/**
* "Russian sites direct": the switch, the two ways to do it as selectable
* cards (right under the switch), a warning for the flexible mode and the
* freshness of the address database with a refresh button.
*/
@Composable
internal fun RuSitesCard(
state: RuIpListState,
onToggle: (Boolean) -> Unit,
onModeChange: (String) -> Unit,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
)
{
ShxCard(modifier = modifier.fillMaxWidth())
{
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.padding(ShxSpacing.lg),
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.toggleable(value = state.enabled, role = Role.Switch, onValueChange = onToggle),
)
{
ShxIconBadge(Icons.Rounded.Public)
Column(
Modifier
.weight(1f)
.padding(horizontal = ShxSpacing.lg),
)
{
Text(
text = stringResource(R.string.rusplit_toggle_title),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
text = stringResource(R.string.rusplit_toggle_summary),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = state.enabled, onCheckedChange = null)
}
AnimatedVisibility(
visible = state.enabled,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
)
{
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.md))
{
Text(
text = stringResource(R.string.routing_mode_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
modifier = Modifier.height(IntrinsicSize.Max),
)
{
val stable = state.mode != Constants.RUSPLIT_MODE_USERSPACE
ModeCard(
title = stringResource(R.string.routing_mode_stable),
description = stringResource(R.string.routing_mode_stable_desc),
selected = stable,
beta = false,
onClick = { onModeChange(Constants.RUSPLIT_MODE_SYSTEM) },
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
)
ModeCard(
title = stringResource(R.string.routing_mode_flexible),
description = stringResource(R.string.routing_mode_flexible_desc),
selected = !stable,
beta = true,
onClick = { onModeChange(Constants.RUSPLIT_MODE_USERSPACE) },
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
)
}
AnimatedVisibility(visible = state.mode == Constants.RUSPLIT_MODE_USERSPACE)
{
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
{
Row(Modifier.padding(ShxSpacing.md), horizontalArrangement = Arrangement.spacedBy(10.dp))
{
Icon(Icons.Rounded.Warning, contentDescription = null, modifier = Modifier.size(18.dp))
Text(
text = stringResource(R.string.routing_mode_flexible_warning),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
DatabaseStatusRow(state = state, onRefresh = onRefresh)
}
}
}
}
}
@Composable
private fun ModeCard(
title: String,
description: String,
selected: Boolean,
beta: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
)
{
Surface(
shape = MaterialTheme.shapes.medium,
color = if (selected) MaterialTheme.colorScheme.primaryContainer else androidx.compose.ui.graphics.Color.Transparent,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
border = if (selected)
{
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
}
else
{
BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
},
modifier = modifier.selectable(selected = selected, role = Role.RadioButton, onClick = onClick),
)
{
Column(Modifier.padding(ShxSpacing.md), verticalArrangement = Arrangement.spacedBy(6.dp))
{
Row(verticalAlignment = Alignment.CenterVertically)
{
RadioButton(selected = selected, onClick = null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(ShxSpacing.sm))
/* FlowRow: on narrow cards the BETA badge drops below the title
* as a whole instead of wrapping letter by letter */
androidx.compose.foundation.layout.FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
itemVerticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f),
)
{
Text(text = title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
if (beta)
{
Surface(
shape = MaterialTheme.shapes.extraSmall,
color = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
)
{
Text(
text = stringResource(R.string.routing_mode_beta),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
softWrap = false,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
}
}
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/** "Address database: N networks - updated 3 h ago" with the refresh button. */
@OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun DatabaseStatusRow(state: RuIpListState, onRefresh: () -> Unit)
{
val text = if (state.lastUpdatedMs > 0L)
{
stringResource(
R.string.routing_db_status,
NumberFormat.getIntegerInstance().format(state.ipv4Ranges + state.ipv6Ranges),
DateUtils.getRelativeTimeSpanString(state.lastUpdatedMs).toString(),
)
}
else
{
stringResource(R.string.routing_db_never)
}
Row(verticalAlignment = Alignment.CenterVertically)
{
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
if (state.status == RuIpListStatus.UPDATING)
{
LoadingIndicator(Modifier.size(24.dp))
Spacer(Modifier.width(ShxSpacing.md))
}
else
{
IconButton(onClick = onRefresh)
{
Icon(
Icons.Rounded.Refresh,
contentDescription = stringResource(R.string.routing_db_refresh),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}