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
+206 -1235

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.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll 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.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.strongswan.android.R 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.ui.compose.components.PredictiveBackScale
import org.strongswan.android.logic.imc.RemediationInstruction import org.strongswan.android.logic.imc.RemediationInstruction
import org.strongswan.android.ui.compose.components.ShxCard import org.strongswan.android.ui.compose.components.ShxCard
@@ -143,7 +141,7 @@ private fun RemediationUi(
navigationIcon = { navigationIcon = {
IconButton(onClick = { if (selected != null) onSelect(null) else onFinish() }) { IconButton(onClick = { if (selected != null) onSelect(null) else onFinish() }) {
Icon( Icon(
Icons.AutoMirrored.Rounded.ArrowBack, ShxIcons.Back,
contentDescription = stringResource(R.string.back), contentDescription = stringResource(R.string.back),
) )
} }
@@ -167,7 +165,7 @@ private fun RemediationUi(
if (instructions.isEmpty()) if (instructions.isEmpty())
{ {
ShxEmptyState( ShxEmptyState(
icon = Icons.Rounded.CheckCircle, icon = ShxIcons.Check,
message = stringResource(R.string.remediation_instructions_title), message = stringResource(R.string.remediation_instructions_title),
modifier = Modifier.padding(ShxSpacing.lg), modifier = Modifier.padding(ShxSpacing.lg),
) )
@@ -43,7 +43,6 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -80,6 +79,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.strongswan.android.R import org.strongswan.android.R
import org.strongswan.android.ui.compose.design.ShxDialogButton
import org.strongswan.android.data.VpnProfile import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfileDataSource import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.data.VpnProfileSource import org.strongswan.android.data.VpnProfileSource
@@ -724,8 +724,8 @@ private fun TextDialog(
text = { text = {
ShxFormField(label = label, value = text, onValueChange = { text = it }, keyboardOptions = KeyboardOptions(keyboardType = keyboard)) 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)) } }, confirmButton = { ShxDialogButton(primary = true, onClick = { onDone(text) }, enabled = validate(text.trim())) { Text(stringResource(R.string.server_save)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } }, 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 androidx.appcompat.app.AppCompatActivity
import org.strongswan.android.ui.compose.design.ShxAlertDialog import org.strongswan.android.ui.compose.design.ShxAlertDialog
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -38,6 +37,7 @@ import java.security.cert.CertificateException
import java.security.cert.CertificateFactory import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import org.strongswan.android.R import org.strongswan.android.R
import org.strongswan.android.ui.compose.design.ShxDialogButton
import org.strongswan.android.logic.TrustedCertificateManager import org.strongswan.android.logic.TrustedCertificateManager
import org.strongswan.android.ui.compose.theme.ShxTheme import org.strongswan.android.ui.compose.theme.ShxTheme
@@ -180,7 +180,7 @@ class TrustedCertificateImportActivity : AppCompatActivity()
title = { Text(stringResource(R.string.import_certificate)) }, title = { Text(stringResource(R.string.import_certificate)) },
text = { Text(certificate.getSubjectDN().toString()) }, text = { Text(certificate.getSubjectDN().toString()) },
confirmButton = { confirmButton = {
TextButton(onClick = { ShxDialogButton(primary = true, onClick = {
if (storeCertificate(certificate)) if (storeCertificate(certificate))
{ {
Toast.makeText(this, R.string.cert_imported_successfully, Toast.LENGTH_LONG).show() 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)) } }) { Text(stringResource(R.string.import_certificate)) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { finish() }) { ShxDialogButton(onClick = { finish() }) {
Text(stringResource(android.R.string.cancel)) 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.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll 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 org.strongswan.android.ui.compose.design.ShxAlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -40,7 +31,6 @@ import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.strongswan.android.R 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.ShxSection
import org.strongswan.android.ui.compose.components.ShxSectionDivider import org.strongswan.android.ui.compose.components.ShxSectionDivider
import org.strongswan.android.ui.compose.components.ShxSettingRow import org.strongswan.android.ui.compose.components.ShxSettingRow
@@ -92,7 +84,7 @@ fun ConnectivityCheckScreen()
ShxSection(title = stringResource(R.string.settings_section_general)) ShxSection(title = stringResource(R.string.settings_section_general))
{ {
ShxSettingToggleRow( ShxSettingToggleRow(
icon = Icons.Rounded.NetworkCheck, icon = ShxIcons.Pulse,
title = stringResource(R.string.health_check_title), title = stringResource(R.string.health_check_title),
subtitle = stringResource(R.string.health_check_summary), subtitle = stringResource(R.string.health_check_summary),
checked = config.enabled, checked = config.enabled,
@@ -100,7 +92,7 @@ fun ConnectivityCheckScreen()
) )
ShxSectionDivider() ShxSectionDivider()
ShxSettingRow( ShxSettingRow(
icon = Icons.Rounded.Timer, icon = ShxIcons.Clock,
title = stringResource(R.string.health_check_interval), title = stringResource(R.string.health_check_interval),
subtitle = intervalLabel(config.intervalSec, config.aggressiveMode), subtitle = intervalLabel(config.intervalSec, config.aggressiveMode),
enabled = config.enabled && !config.aggressiveMode, enabled = config.enabled && !config.aggressiveMode,
@@ -108,7 +100,7 @@ fun ConnectivityCheckScreen()
) )
ShxSectionDivider() ShxSectionDivider()
ShxSettingToggleRow( ShxSettingToggleRow(
icon = Icons.Rounded.Speed, icon = ShxIcons.Bolt,
title = stringResource(R.string.health_check_aggressive_title), title = stringResource(R.string.health_check_aggressive_title),
subtitle = stringResource(R.string.health_check_aggressive_summary), subtitle = stringResource(R.string.health_check_aggressive_summary),
checked = config.aggressiveMode, checked = config.aggressiveMode,
@@ -116,7 +108,7 @@ fun ConnectivityCheckScreen()
) )
ShxSectionDivider() ShxSectionDivider()
ShxSettingToggleRow( ShxSettingToggleRow(
icon = Icons.Rounded.BatterySaver, icon = ShxIcons.Battery,
title = stringResource(R.string.health_check_adaptive_title), title = stringResource(R.string.health_check_adaptive_title),
subtitle = stringResource(R.string.health_check_adaptive_summary), subtitle = stringResource(R.string.health_check_adaptive_summary),
checked = config.adaptiveMode, checked = config.adaptiveMode,
@@ -127,7 +119,7 @@ fun ConnectivityCheckScreen()
ShxSection(title = stringResource(R.string.health_check_targets_title)) ShxSection(title = stringResource(R.string.health_check_targets_title))
{ {
ShxSettingToggleRow( ShxSettingToggleRow(
icon = Icons.Rounded.HealthAndSafety, icon = ShxIcons.Shield,
title = stringResource(R.string.health_check_server_title), title = stringResource(R.string.health_check_server_title),
subtitle = stringResource(R.string.health_check_server_summary), subtitle = stringResource(R.string.health_check_server_summary),
checked = config.serverCheckMode != ServerCheckMode.OFF, checked = config.serverCheckMode != ServerCheckMode.OFF,
@@ -140,7 +132,7 @@ fun ConnectivityCheckScreen()
listOf(ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE).forEach { target -> listOf(ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE).forEach { target ->
ShxSectionDivider() ShxSectionDivider()
ShxSettingToggleRow( ShxSettingToggleRow(
icon = Icons.Rounded.Public, icon = ShxIcons.Globe,
title = targetLabel(target), title = targetLabel(target),
subtitle = null, subtitle = null,
checked = config.targets.contains(target), checked = config.targets.contains(target),
@@ -175,7 +167,7 @@ fun ConnectivityCheckScreen()
} }
ShxSectionDivider() ShxSectionDivider()
ShxSettingRow( ShxSettingRow(
icon = Icons.Rounded.NetworkCheck, icon = ShxIcons.Pulse,
title = stringResource(R.string.health_check_action), title = stringResource(R.string.health_check_action),
subtitle = actionLabel(config.serverAction), subtitle = actionLabel(config.serverAction),
enabled = config.enabled, enabled = config.enabled,
@@ -203,7 +195,7 @@ fun ConnectivityCheckScreen()
ShxSection(title = stringResource(R.string.health_check_tunnel_dead_title)) ShxSection(title = stringResource(R.string.health_check_tunnel_dead_title))
{ {
ShxSettingRow( ShxSettingRow(
icon = Icons.Rounded.NetworkCheck, icon = ShxIcons.Pulse,
title = stringResource(R.string.health_check_action), title = stringResource(R.string.health_check_action),
subtitle = actionLabel(config.tunnelDeadAction), subtitle = actionLabel(config.tunnelDeadAction),
enabled = config.enabled, enabled = config.enabled,
@@ -231,7 +223,7 @@ fun ConnectivityCheckScreen()
ShxSection(title = stringResource(R.string.health_check_partial_title)) ShxSection(title = stringResource(R.string.health_check_partial_title))
{ {
ShxSettingRow( ShxSettingRow(
icon = Icons.Rounded.NetworkCheck, icon = ShxIcons.Pulse,
title = stringResource(R.string.health_check_action), title = stringResource(R.string.health_check_action),
subtitle = actionLabel(config.partialDownAction), subtitle = actionLabel(config.partialDownAction),
enabled = config.enabled, enabled = config.enabled,
@@ -276,7 +268,7 @@ fun ConnectivityCheckScreen()
} }
}, },
confirmButton = { confirmButton = {
TextButton(onClick = { showIntervalPicker = false }) { ShxDialogButton(onClick = { showIntervalPicker = false }) {
Text(stringResource(android.R.string.cancel)) Text(stringResource(android.R.string.cancel))
} }
}, },
@@ -341,7 +333,7 @@ private fun AttemptsRow(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
IconButton(onClick = onDecrement, enabled = enabled && attempts > 1) { 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(
text = attempts.toString(), text = attempts.toString(),
@@ -349,7 +341,7 @@ private fun AttemptsRow(
modifier = Modifier.padding(horizontal = ShxSpacing.sm), modifier = Modifier.padding(horizontal = ShxSpacing.sm),
) )
IconButton(onClick = onIncrement, enabled = enabled && attempts < 5) { 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.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape 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 org.strongswan.android.ui.compose.design.ShxAlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults 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.snoozeCertExpiry
import org.strongswan.android.ui.compose.home.connectProgress import org.strongswan.android.ui.compose.home.connectProgress
import org.strongswan.android.R 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 androidx.compose.foundation.layout.widthIn
import org.strongswan.android.logic.routing.RuIpListManager import org.strongswan.android.logic.routing.RuIpListManager
import org.strongswan.android.ui.DiagnosticsActivity 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.components.ShxEmptyState
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ScreenPadding import org.strongswan.android.ui.compose.theme.ScreenPadding
import androidx.compose.material.icons.rounded.Check
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.heading 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 pickerOpen by remember { mutableStateOf(false) }
var pauseSheetOpen by remember { mutableStateOf(false) } var pauseSheetOpen by remember { mutableStateOf(false) }
val networkKinds by viewModel.networkKinds.collectAsStateWithLifecycle() val networkKinds by viewModel.networkKinds.collectAsStateWithLifecycle()
@@ -385,7 +372,7 @@ fun HomeScreen(
}, },
onLongPick = { profile -> onLongPick = { profile ->
pickerOpen = false pickerOpen = false
sheetProfile = profile context.startActivity(org.strongswan.android.ui.ServerActivity.intent(context, profile.getUUID().toString()))
}, },
onManage = { onManage = {
pickerOpen = false 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. */ /** «Server» sheet of Home: the servers as on F_Servers, manage / add below. */
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun ProfilePickerSheet( private fun ProfilePickerSheet(
profiles: List<VpnProfile>, profiles: List<VpnProfile>,
@@ -492,240 +420,58 @@ private fun ProfilePickerSheet(
onAdd: () -> Unit, onAdd: () -> Unit,
) )
{ {
val c = LocalShxColors.current
/* the current profile first, the rest in list order */ /* the current profile first, the rest in list order */
val ordered = remember(profiles, selected) { val ordered = remember(profiles, selected) {
profiles.sortedByDescending { it.getUUID() == selected?.getUUID() } profiles.sortedByDescending { it.getUUID() == selected?.getUUID() }
} }
ModalBottomSheet( ShxSheet(onDismiss = onDismiss)
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
)
{ {
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 -> ordered.forEachIndexed { index, profile ->
ShxProfileRow( val active = profile.getUUID() == activeUuid
profile = profile, val chosen = profile.getUUID() == selected?.getUUID()
active = profile.getUUID() == activeUuid, Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().height(68.dp)
.combinedClickable(
onClick = { onPick(profile) }, onClick = { onPick(profile) },
onLongClick = { onLongPick(profile) }, onLongClick = { onLongPick(profile) },
onLongClickLabel = stringResource(R.string.profile_actions), 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,
) )
.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) 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), Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
horizontalArrangement = Arrangement.SpaceBetween,
)
{ {
TextButton(onClick = onManage) { Text(stringResource(R.string.home_manage_profiles)) } ShxButton(stringResource(R.string.home_manage_profiles), onManage, style = ShxButtonStyle.SOFT, height = 48.dp, modifier = Modifier.weight(1f))
TextButton(onClick = onAdd) { ShxButton(stringResource(R.string.add_profile_cta), onAdd, style = ShxButtonStyle.OUTLINE, height = 48.dp, modifier = Modifier.weight(1f), icon = ShxIcons.Plus)
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(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))
}
}
}
@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.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll 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.Button
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
@@ -410,7 +404,7 @@ fun ProfileFormScreen(
trailing = { trailing = {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(CircleShape).clickable { showPassword = !showPassword }) 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.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed 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.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -172,7 +160,7 @@ fun ProfilesScreen(
{ {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) 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), 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.ShxCard
import org.strongswan.android.ui.compose.components.ShxSnackbarHost import org.strongswan.android.ui.compose.components.ShxSnackbarHost
import org.strongswan.android.ui.compose.routing.DeadHostsPage 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.DirectAppsScreen
import org.strongswan.android.ui.compose.routing.RecentAppsPage 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.RulesPage
import org.strongswan.android.ui.compose.routing.routingSummary import org.strongswan.android.ui.compose.routing.routingSummary
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors 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.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll 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 org.strongswan.android.ui.compose.design.ShxAlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -77,7 +55,6 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Brightness6 import androidx.compose.material.icons.rounded.Brightness6
import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SegmentedButtonDefaults
@@ -101,6 +78,7 @@ import androidx.compose.runtime.produceState
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.strongswan.android.R import org.strongswan.android.R
import org.strongswan.android.ui.compose.design.ShxDialogButton
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -284,14 +262,14 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
ShxSectionDivider() ShxSectionDivider()
} }
ShxSettingRow( ShxSettingRow(
icon = Icons.Rounded.History, icon = ShxIcons.Clock,
title = stringResource(R.string.pref_default_vpn_profile), title = stringResource(R.string.pref_default_vpn_profile),
subtitle = defaultLabel, subtitle = defaultLabel,
onClick = { showDefaultPicker = true }, onClick = { showDefaultPicker = true },
) )
ShxSectionDivider() ShxSectionDivider()
ShxSettingToggleRow( ShxSettingToggleRow(
icon = Icons.Rounded.BatteryChargingFull, icon = ShxIcons.Battery,
title = stringResource(R.string.pref_power_whitelist_title), title = stringResource(R.string.pref_power_whitelist_title),
subtitle = stringResource(R.string.pref_power_whitelist_summary), subtitle = stringResource(R.string.pref_power_whitelist_summary),
checked = ignorePower, checked = ignorePower,
@@ -306,7 +284,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
* disabled, which breaks watchdog-based recovery of the tunnel */ * disabled, which breaks watchdog-based recovery of the tunnel */
ShxSectionDivider() ShxSectionDivider()
ShxSettingRow( ShxSettingRow(
icon = Icons.Rounded.Bedtime, icon = ShxIcons.Moon,
title = stringResource(R.string.samsung_sleep_title), title = stringResource(R.string.samsung_sleep_title),
subtitle = stringResource(R.string.samsung_sleep_summary), subtitle = stringResource(R.string.samsung_sleep_summary),
onClick = { openSamsungBatterySettings(context) }, onClick = { openSamsungBatterySettings(context) },
@@ -323,7 +301,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
mutableStateOf(WhiteListManager.getInstance(context).currentConfig.enabled) mutableStateOf(WhiteListManager.getInstance(context).currentConfig.enabled)
} }
ShxSettingToggleRow( ShxSettingToggleRow(
icon = Icons.Rounded.GppMaybe, icon = ShxIcons.Alert,
title = stringResource(R.string.whitelist_analysis_title), title = stringResource(R.string.whitelist_analysis_title),
subtitle = stringResource(R.string.whitelist_analysis_summary), subtitle = stringResource(R.string.whitelist_analysis_summary),
checked = whiteListEnabled, checked = whiteListEnabled,
@@ -337,7 +315,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
mutableStateOf(VpnShortcutManager.getInstance(context).isEnabled()) mutableStateOf(VpnShortcutManager.getInstance(context).isEnabled())
} }
ShxSettingToggleRow( ShxSettingToggleRow(
icon = Icons.Rounded.Bolt, icon = ShxIcons.Bolt,
title = stringResource(R.string.pref_routines_shortcuts_title), title = stringResource(R.string.pref_routines_shortcuts_title),
subtitle = stringResource(R.string.pref_routines_shortcuts_summary), subtitle = stringResource(R.string.pref_routines_shortcuts_summary),
checked = shortcutsEnabled, checked = shortcutsEnabled,
@@ -351,7 +329,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
mutableStateOf(prefs.getBoolean(Constants.PREF_WATCHDOG_ENABLED, true)) mutableStateOf(prefs.getBoolean(Constants.PREF_WATCHDOG_ENABLED, true))
} }
ShxSettingToggleRow( ShxSettingToggleRow(
icon = Icons.Rounded.RestartAlt, icon = ShxIcons.Refresh,
title = stringResource(R.string.watchdog_title), title = stringResource(R.string.watchdog_title),
subtitle = stringResource(R.string.watchdog_summary), subtitle = stringResource(R.string.watchdog_summary),
checked = watchdogEnabled, checked = watchdogEnabled,
@@ -377,7 +355,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
mutableStateOf(prefs.getBoolean(Constants.PREF_SAFE_IPV6, false)) mutableStateOf(prefs.getBoolean(Constants.PREF_SAFE_IPV6, false))
} }
ShxSettingToggleRow( ShxSettingToggleRow(
icon = Icons.Rounded.GppGood, icon = ShxIcons.Shield,
title = stringResource(R.string.safe_ipv6_title), title = stringResource(R.string.safe_ipv6_title),
subtitle = stringResource(R.string.safe_ipv6_summary), subtitle = stringResource(R.string.safe_ipv6_summary),
checked = safeIpv6, checked = safeIpv6,
@@ -394,7 +372,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
ShxSection(title = stringResource(R.string.settings_section_certs)) ShxSection(title = stringResource(R.string.settings_section_certs))
{ {
ShxSettingRow( ShxSettingRow(
icon = Icons.Rounded.VerifiedUser, icon = ShxIcons.Cert,
title = stringResource(R.string.trusted_certs_title), title = stringResource(R.string.trusted_certs_title),
onClick = { onClick = {
context.startActivity(Intent(context, TrustedCertificatesActivity::class.java)) context.startActivity(Intent(context, TrustedCertificatesActivity::class.java))
@@ -402,7 +380,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
) )
ShxSectionDivider() ShxSectionDivider()
ShxSettingRow( ShxSettingRow(
icon = Icons.Rounded.Cached, icon = ShxIcons.Refresh,
title = stringResource(R.string.crl_cache), title = stringResource(R.string.crl_cache),
onClick = { showCrl = true }, onClick = { showCrl = true },
) )
@@ -428,7 +406,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
mutableStateOf(prefs.getBoolean(Constants.PREF_EXTENDED_DIAG, false)) mutableStateOf(prefs.getBoolean(Constants.PREF_EXTENDED_DIAG, false))
} }
ShxSettingToggleRow( ShxSettingToggleRow(
icon = Icons.Rounded.BugReport, icon = ShxIcons.Bug,
title = stringResource(R.string.extended_diag_title), title = stringResource(R.string.extended_diag_title),
subtitle = stringResource(R.string.extended_diag_summary), subtitle = stringResource(R.string.extended_diag_summary),
checked = extendedDiag, checked = extendedDiag,
@@ -476,7 +454,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
mutableStateOf(prefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true)) mutableStateOf(prefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true))
} }
ShxSettingToggleRow( ShxSettingToggleRow(
icon = Icons.Rounded.Autorenew, icon = ShxIcons.Refresh,
title = stringResource(R.string.update_auto_check_title), title = stringResource(R.string.update_auto_check_title),
subtitle = stringResource(R.string.update_auto_check_summary), subtitle = stringResource(R.string.update_auto_check_summary),
checked = autoUpdate, checked = autoUpdate,
@@ -490,7 +468,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
mutableStateOf(prefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)) mutableStateOf(prefs.getBoolean(Constants.PREF_SILENT_UPDATES, false))
} }
ShxSettingToggleRow( ShxSettingToggleRow(
icon = Icons.Rounded.CloudSync, icon = ShxIcons.Refresh,
title = stringResource(R.string.update_silent_title), title = stringResource(R.string.update_silent_title),
subtitle = stringResource(R.string.update_silent_summary), subtitle = stringResource(R.string.update_silent_summary),
checked = silentUpdates, checked = silentUpdates,
@@ -501,7 +479,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
) )
ShxSectionDivider() ShxSectionDivider()
ShxSettingRow( ShxSettingRow(
icon = Icons.Rounded.SystemUpdateAlt, icon = ShxIcons.Download,
title = stringResource(R.string.update_check_title), title = stringResource(R.string.update_check_title),
subtitle = updateStatusText(updateState), subtitle = updateStatusText(updateState),
onClick = { onClick = {
@@ -518,7 +496,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
{ {
ShxSectionDivider() ShxSectionDivider()
ShxSettingRow( ShxSettingRow(
icon = Icons.Rounded.CloudDownload, icon = ShxIcons.Download,
title = stringResource(R.string.update_download_install), title = stringResource(R.string.update_download_install),
subtitle = when (val state = updateState) subtitle = when (val state = updateState)
{ {
@@ -583,7 +561,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
} }
}, },
confirmButton = { confirmButton = {
TextButton(onClick = { showDefaultPicker = false }) { ShxDialogButton(onClick = { showDefaultPicker = false }) {
Text(stringResource(android.R.string.cancel)) Text(stringResource(android.R.string.cancel))
} }
}, },
@@ -603,7 +581,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
) )
}, },
confirmButton = { confirmButton = {
TextButton(onClick = { ShxDialogButton(primary = true, danger = true, onClick = {
if (files.isEmpty()) if (files.isEmpty())
{ {
scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.clear_crl_cache_msg_none)) } 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)) } }) { Text(stringResource(R.string.clear)) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showCrl = false }) { ShxDialogButton(onClick = { showCrl = false }) {
Text(stringResource(android.R.string.cancel)) 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)) }, title = { Text(stringResource(R.string.update_confirm_title, info.versionName)) },
text = { if (notes.isNotEmpty()) { Text(notes) } }, text = { if (notes.isNotEmpty()) { Text(notes) } },
confirmButton = { confirmButton = {
TextButton(onClick = { ShxDialogButton(primary = true, onClick = {
showUpdateConfirm = false showUpdateConfirm = false
startUpdate() startUpdate()
}) { Text(stringResource(R.string.update_action)) } }) { Text(stringResource(R.string.update_action)) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showUpdateConfirm = false }) { ShxDialogButton(onClick = { showUpdateConfirm = false }) {
Text(stringResource(android.R.string.cancel)) 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)) }, title = { Text(stringResource(R.string.kill_switch_dialog_title)) },
text = { Text(stringResource(R.string.kill_switch_dialog_text)) }, text = { Text(stringResource(R.string.kill_switch_dialog_text)) },
confirmButton = { confirmButton = {
TextButton(onClick = { ShxDialogButton(primary = true, onClick = {
showKillSwitchDialog = false showKillSwitchDialog = false
context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS)) context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
}) { Text(stringResource(R.string.kill_switch_dialog_enable)) } }) { Text(stringResource(R.string.kill_switch_dialog_enable)) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showKillSwitchDialog = false }) { ShxDialogButton(onClick = { showKillSwitchDialog = false }) {
Text(stringResource(android.R.string.cancel)) Text(stringResource(android.R.string.cancel))
} }
}, },
@@ -999,20 +977,20 @@ private fun KillSwitchRow()
title = { Text(stringResource(R.string.kill_switch_dialog_title)) }, title = { Text(stringResource(R.string.kill_switch_dialog_title)) },
text = { Text(stringResource(R.string.kill_switch_dialog_text)) }, text = { Text(stringResource(R.string.kill_switch_dialog_text)) },
confirmButton = { confirmButton = {
TextButton(onClick = { ShxDialogButton(primary = true, onClick = {
showKillSwitchDialog = false showKillSwitchDialog = false
context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS)) context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
}) { Text(stringResource(R.string.kill_switch_dialog_enable)) } }) { Text(stringResource(R.string.kill_switch_dialog_enable)) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showKillSwitchDialog = false }) { ShxDialogButton(onClick = { showKillSwitchDialog = false }) {
Text(stringResource(android.R.string.cancel)) Text(stringResource(android.R.string.cancel))
} }
}, },
) )
} }
ShxSettingRow( ShxSettingRow(
icon = Icons.Rounded.Security, icon = ShxIcons.Shield,
title = stringResource(R.string.kill_switch_title), title = stringResource(R.string.kill_switch_title),
subtitle = stringResource( subtitle = stringResource(
if (lockdownActive) R.string.kill_switch_summary_on 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.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll 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 org.strongswan.android.ui.compose.design.ShxAlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -67,6 +62,7 @@ import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import org.strongswan.android.R 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.ShxSection
import org.strongswan.android.ui.compose.components.ShxSectionDivider import org.strongswan.android.ui.compose.components.ShxSectionDivider
import org.strongswan.android.ui.compose.components.ShxSettingRow import org.strongswan.android.ui.compose.components.ShxSettingRow
@@ -169,7 +165,7 @@ fun TrustedNetworksScreen(
ShxSection(title = stringResource(R.string.settings_section_general)) ShxSection(title = stringResource(R.string.settings_section_general))
{ {
ShxSettingToggleRow( ShxSettingToggleRow(
icon = Icons.Rounded.Wifi, icon = ShxIcons.Wifi,
title = stringResource(R.string.auto_connect_title), title = stringResource(R.string.auto_connect_title),
subtitle = stringResource(R.string.auto_connect_summary), subtitle = stringResource(R.string.auto_connect_summary),
checked = enabled, checked = enabled,
@@ -181,7 +177,7 @@ fun TrustedNetworksScreen(
) )
ShxSectionDivider() ShxSectionDivider()
ShxSettingRow( ShxSettingRow(
icon = Icons.Rounded.Language, icon = ShxIcons.Globe,
title = stringResource(R.string.auto_connect_profile), title = stringResource(R.string.auto_connect_profile),
subtitle = selectedProfile?.name subtitle = selectedProfile?.name
?: stringResource(R.string.auto_connect_profile_none), ?: stringResource(R.string.auto_connect_profile_none),
@@ -209,7 +205,7 @@ fun TrustedNetworksScreen(
{ {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md)) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md))
{ {
Icon(Icons.Rounded.Wifi, contentDescription = null) Icon(ShxIcons.Wifi, contentDescription = null)
Text( Text(
text = stringResource(R.string.auto_connect_location_title), text = stringResource(R.string.auto_connect_location_title),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
@@ -269,7 +265,7 @@ fun TrustedNetworksScreen(
) )
{ {
Icon( Icon(
Icons.Rounded.Wifi, ShxIcons.Wifi,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
@@ -283,7 +279,7 @@ fun TrustedNetworksScreen(
) )
IconButton(onClick = { pendingDeleteSsid = ssid }) { IconButton(onClick = { pendingDeleteSsid = ssid }) {
Icon( Icon(
Icons.Rounded.Delete, ShxIcons.Trash,
contentDescription = stringResource(R.string.delete_profile), contentDescription = stringResource(R.string.delete_profile),
tint = MaterialTheme.colorScheme.error, tint = MaterialTheme.colorScheme.error,
) )
@@ -410,7 +406,7 @@ private fun TrustedAddNetworkRow(
{ {
TextButton(onClick = { onAddCurrent(currentSsid) }) 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)) Spacer(Modifier.size(ShxSpacing.sm))
Text(stringResource(R.string.auto_connect_add_current, currentSsid)) Text(stringResource(R.string.auto_connect_add_current, currentSsid))
} }
@@ -419,7 +415,7 @@ private fun TrustedAddNetworkRow(
{ {
TextButton(onClick = onAddManual) 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)) Spacer(Modifier.size(ShxSpacing.sm))
Text(stringResource(R.string.auto_connect_add_network)) 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.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions 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.ShxAlertDialog
import org.strongswan.android.ui.compose.design.ShxFormField import org.strongswan.android.ui.compose.design.ShxFormField
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -29,7 +26,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.R 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.ShxSpacing
import org.strongswan.android.ui.compose.theme.ShxTheme import org.strongswan.android.ui.compose.theme.ShxTheme
@@ -182,7 +179,7 @@ private fun ConfirmationUi(dialog: VpnDialog.Confirmation)
Row(horizontalArrangement = Arrangement.spacedBy(ShxSpacing.xs)) { Row(horizontalArrangement = Arrangement.spacedBy(ShxSpacing.xs)) {
if (dialog.reconnect) if (dialog.reconnect)
{ {
TextButton(onClick = { ShxDialogButton(onClick = {
VpnDialogBus.dismiss() VpnDialogBus.dismiss()
VpnDialogBus.listener?.onVpnDialogDisconnect() VpnDialogBus.listener?.onVpnDialogDisconnect()
}) { }) {
@@ -192,7 +189,7 @@ private fun ConfirmationUi(dialog: VpnDialog.Confirmation)
) )
} }
} }
TextButton(onClick = { ShxDialogButton(primary = true, onClick = {
VpnDialogBus.dismiss() VpnDialogBus.dismiss()
val listener = VpnDialogBus.listener val listener = VpnDialogBus.listener
if (dialog.disconnect) if (dialog.disconnect)
@@ -207,7 +204,7 @@ private fun ConfirmationUi(dialog: VpnDialog.Confirmation)
} }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = ::cancelDialog) { ShxDialogButton(onClick = ::cancelDialog) {
Text(stringResource(android.R.string.cancel)) Text(stringResource(android.R.string.cancel))
} }
}, },
@@ -245,7 +242,7 @@ private fun LoginUi(dialog: VpnDialog.Login)
trailing = { trailing = {
IconButton(onClick = { showPassword = !showPassword }) { IconButton(onClick = { showPassword = !showPassword }) {
Icon( Icon(
if (showPassword) Icons.Rounded.Visibility else Icons.Rounded.VisibilityOff, if (showPassword) ShxIcons.Eye else ShxIcons.EyeOff,
contentDescription = stringResource( contentDescription = stringResource(
if (showPassword) R.string.login_hide_password else R.string.login_show_password if (showPassword) R.string.login_hide_password else R.string.login_show_password
), ),
@@ -256,13 +253,13 @@ private fun LoginUi(dialog: VpnDialog.Login)
} }
}, },
confirmButton = { confirmButton = {
TextButton(onClick = { ShxDialogButton(primary = true, onClick = {
VpnDialogBus.dismiss() VpnDialogBus.dismiss()
VpnDialogBus.listener?.onVpnDialogLogin(dialog.profileInfo, password) VpnDialogBus.listener?.onVpnDialogLogin(dialog.profileInfo, password)
}) { Text(stringResource(R.string.login_confirm)) } }) { Text(stringResource(R.string.login_confirm)) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = ::cancelDialog) { ShxDialogButton(onClick = ::cancelDialog) {
Text(stringResource(android.R.string.cancel)) Text(stringResource(android.R.string.cancel))
} }
}, },
@@ -277,13 +274,13 @@ private fun CertificateRequiredUi(dialog: VpnDialog.CertificateRequired)
title = { Text(stringResource(R.string.certificate_required_title)) }, title = { Text(stringResource(R.string.certificate_required_title)) },
text = { Text(stringResource(R.string.certificate_required_text)) }, text = { Text(stringResource(R.string.certificate_required_text)) },
confirmButton = { confirmButton = {
TextButton(onClick = { ShxDialogButton(primary = true, onClick = {
VpnDialogBus.dismiss() VpnDialogBus.dismiss()
VpnDialogBus.listener?.onVpnDialogEditProfile(dialog.uuid) VpnDialogBus.listener?.onVpnDialogEditProfile(dialog.uuid)
}) { Text(stringResource(R.string.edit_profile)) } }) { Text(stringResource(R.string.edit_profile)) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = ::cancelDialog) { ShxDialogButton(onClick = ::cancelDialog) {
Text(stringResource(android.R.string.cancel)) Text(stringResource(android.R.string.cancel))
} }
}, },
@@ -298,7 +295,7 @@ private fun PowerWhitelistUi()
title = { Text(stringResource(R.string.power_whitelist_title)) }, title = { Text(stringResource(R.string.power_whitelist_title)) },
text = { Text(stringResource(R.string.power_whitelist_text)) }, text = { Text(stringResource(R.string.power_whitelist_text)) },
confirmButton = { confirmButton = {
TextButton(onClick = { ShxDialogButton(primary = true, onClick = {
VpnDialogBus.dismiss() VpnDialogBus.dismiss()
VpnDialogBus.listener?.onVpnDialogPowerWhitelist() VpnDialogBus.listener?.onVpnDialogPowerWhitelist()
}) { Text(stringResource(android.R.string.ok)) } }) { 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)) }, title = { Text(stringResource(R.string.vpn_not_supported_title)) },
text = { Text(stringResource(dialog.message)) }, text = { Text(stringResource(dialog.message)) },
confirmButton = { confirmButton = {
TextButton(onClick = ::cancelDialog) { ShxDialogButton(primary = true, onClick = ::cancelDialog) {
Text(stringResource(android.R.string.ok)) 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 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 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 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.verticalScroll
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions 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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet 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), 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( Text(
title, title,
@@ -259,7 +256,7 @@ fun ShxRow(
trailing?.invoke(this) trailing?.invoke(this)
if (chevron) 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) 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,
)
}
}
}
}