android: Servers, Settings and Apps direct screens as in the Final 2026 mockup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-12 09:43:13 +07:00
1 parent 4ff5ed11bb
commit 434e5bad4e
7 files changed
+688 -448

No files matched your search

@@ -71,6 +71,41 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.saveable.rememberSaveable
import org.strongswan.android.R
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.produceState
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import org.strongswan.android.logic.connectivity.ConnectivityProbe
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxButtonStyle
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxRow
import org.strongswan.android.ui.compose.design.ShxScreenTitle
import org.strongswan.android.ui.compose.design.ShxSectionHeader
import org.strongswan.android.ui.compose.design.ShxSheet
import org.strongswan.android.ui.compose.design.ShxSheetTitle
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.logic.VpnStateService.State
import org.strongswan.android.ui.compose.components.ShxCard
@@ -92,11 +127,12 @@ fun ProfilesScreen(
onRestore: (VpnProfile) -> Unit,
onShare: (VpnProfile) -> Unit,
onAdd: () -> Unit,
onFastest: () -> Unit = {},
)
{
if (LocalConfiguration.current.screenWidthDp < 840)
{
ProfilesList(state, onConnect, onDisconnect, onEdit, onDelete, onRestore, onShare, onAdd)
ProfilesList(state, onConnect, onDisconnect, onEdit, onDelete, onRestore, onShare, onAdd, onFastest)
return
}
/* null = nothing open, "" = new profile, otherwise the edited profile's uuid */
@@ -143,7 +179,9 @@ fun ProfilesScreen(
)
}
@OptIn(ExperimentalMaterial3Api::class)
/** Server status line: green "connected · 38 ms", green "available · 29 ms" or amber "no answer". */
private data class ServerPing(val rttMs: Long?)
@Composable
private fun ProfilesList(
state: HomeUiState,
@@ -154,21 +192,32 @@ private fun ProfilesList(
onRestore: (VpnProfile) -> Unit,
onShare: (VpnProfile) -> Unit,
onAdd: () -> Unit,
onFastest: () -> Unit = {},
)
{
val context = LocalContext.current
val c = LocalShxColors.current
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
var query by remember { mutableStateOf("") }
var menuFor by remember { mutableStateOf<VpnProfile?>(null) }
val activeUuid = state.activeProfile?.getUUID()
val connected = state.vpnState == State.CONNECTED
val profiles = state.profiles
val filtered = remember(query, profiles)
{
if (query.isBlank()) profiles
else profiles.filter {
(it.name ?: "").contains(query, ignoreCase = true) || (it.gateway ?: "").contains(query, ignoreCase = true)
/* direct pings of every server (outside the tunnel), refreshed while the tab is shown */
val lifecycle = androidx.lifecycle.compose.LocalLifecycleOwner.current.lifecycle
val pings by produceState(emptyMap<String, ServerPing>(), profiles, lifecycle) {
lifecycle.repeatOnLifecycle(androidx.lifecycle.Lifecycle.State.STARTED) {
while (true)
{
value = coroutineScope {
profiles.map { p ->
async {
p.getUUID().toString() to ServerPing(ConnectivityProbe.pingServerDirect(context, p.gateway, 1500)?.first)
}
}.map { it.await() }.toMap()
}
delay(30_000)
}
}
}
@@ -188,183 +237,183 @@ private fun ProfilesList(
}
}
Scaffold(
snackbarHost = { ShxSnackbarHost(snackbarHostState) },
containerColor = MaterialTheme.colorScheme.background,
) { _ ->
Column(Modifier.fillMaxSize())
Box(Modifier.fillMaxSize().background(c.background))
{
Column(Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()))
{
TopAppBar(
title = { Text(stringResource(R.string.nav_profiles), fontWeight = FontWeight.SemiBold) },
actions = {
IconButton(onClick = onAdd) {
Icon(Icons.Rounded.Add, contentDescription = stringResource(R.string.add_profile_cta))
}
},
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
SearchBar(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.xs),
inputField = {
SearchBarDefaults.InputField(
query = query,
onQueryChange = { query = it },
onSearch = {},
expanded = false,
onExpandedChange = {},
placeholder = { Text(stringResource(R.string.search_profiles)) },
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
trailingIcon = {
if (query.isNotEmpty())
{
IconButton(onClick = { query = "" }) {
Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.action_clear_search))
}
}
},
)
},
expanded = false,
onExpandedChange = {},
) {}
if (filtered.isEmpty())
ShxScreenTitle(stringResource(R.string.nav_profiles))
{
EmptyProfilesState(query = query, onAdd = onAdd)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(44.dp).clip(CircleShape).background(c.accent)
.clickable(role = Role.Button, onClickLabel = stringResource(R.string.add_profile_cta), onClick = onAdd),
)
{
Icon(ShxIcons.Plus, contentDescription = stringResource(R.string.add_profile_cta), tint = Color.White, modifier = Modifier.size(22.dp))
}
}
else
Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
) {
LazyColumn {
itemsIndexed(filtered, key = { _, p -> p.getUUID().toString() }) { index, profile ->
val isActive = connected && profile.getUUID() == activeUuid
Column(Modifier.animateItem()) {
Box {
ShxProfileRow(
profile = profile,
active = isActive,
onClick = { if (isActive) onDisconnect() else onConnect(profile) },
onLongClick = { menuFor = profile },
trailing = {
IconButton(onClick = { menuFor = profile }) {
Icon(
Icons.Rounded.MoreVert,
contentDescription = stringResource(R.string.action_more_options),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (isActive) ActiveBadge()
},
)
DropdownMenu(expanded = menuFor == profile, onDismissRequest = { menuFor = null })
{
if (isActive)
{
DropdownMenuItem(
text = { Text(stringResource(R.string.disconnect)) },
leadingIcon = { Icon(Icons.Rounded.Stop, contentDescription = null) },
onClick = {
menuFor = null
onDisconnect()
},
)
}
else
{
DropdownMenuItem(
text = { Text(stringResource(R.string.connect)) },
leadingIcon = { Icon(Icons.Rounded.PlayArrow, contentDescription = null) },
onClick = {
menuFor = null
onConnect(profile)
},
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.edit_profile)) },
leadingIcon = { Icon(Icons.Rounded.Edit, contentDescription = null) },
onClick = {
menuFor = null
onEdit(profile)
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.share_profile)) },
leadingIcon = { Icon(Icons.Rounded.Share, contentDescription = null) },
onClick = {
menuFor = null
onShare(profile)
},
)
DropdownMenuItem(
text = {
Text(
stringResource(R.string.delete_profile),
color = MaterialTheme.colorScheme.error,
)
},
leadingIcon = {
Icon(
Icons.Rounded.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
onClick = {
menuFor = null
deleteWithUndo(profile)
},
)
}
}
if (index < filtered.lastIndex)
{
ShxDivider()
}
}
if (profiles.isEmpty())
{
EmptyProfilesState(onAdd)
return@Column
}
if (profiles.size > 1)
{
AutoServerCard(onFastest)
}
ShxSectionHeader(stringResource(R.string.servers_mine, profiles.size), Modifier.padding(top = 8.dp))
ShxPanel()
{
profiles.forEachIndexed { index, profile ->
val isActive = connected && profile.getUUID() == activeUuid
ServerRow(
profile = profile,
active = isActive,
ping = pings[profile.getUUID().toString()],
onClick = { if (isActive) onDisconnect() else onConnect(profile) },
onMore = { menuFor = profile },
)
if (index < profiles.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
}
}
Text(stringResource(R.string.servers_note), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
}
}
ShxSnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter))
}
menuFor?.let { profile ->
val isActive = connected && profile.getUUID() == activeUuid
ShxSheet(onDismiss = { menuFor = null })
{
ShxSheetTitle(profile.name ?: profile.gateway ?: "")
Text(profile.gateway ?: "", style = ShxType.note.copy(fontSize = 14.sp), color = c.textSub, modifier = Modifier.padding(horizontal = 4.dp))
ShxPanel()
{
ShxRow(
title = stringResource(if (isActive) R.string.disconnect else R.string.connect),
icon = if (isActive) ShxIcons.Close else ShxIcons.Power,
divider = true, minHeight = 64.dp,
onClick = {
menuFor = null
if (isActive) onDisconnect() else onConnect(profile)
},
)
ShxRow(
title = stringResource(R.string.edit_profile),
icon = ShxIcons.Sliders, iconBackground = c.tile,
divider = true, minHeight = 64.dp,
onClick = {
menuFor = null
onEdit(profile)
},
)
ShxRow(
title = stringResource(R.string.share_profile),
icon = ShxIcons.Download, iconBackground = c.tile,
minHeight = 64.dp,
onClick = {
menuFor = null
onShare(profile)
},
)
}
ShxButton(
stringResource(R.string.delete_profile),
{
menuFor = null
deleteWithUndo(profile)
},
style = ShxButtonStyle.DANGER,
modifier = Modifier.fillMaxWidth(),
icon = ShxIcons.Trash,
)
}
}
}
private const val SEARCH_THRESHOLD = 5
/** «Automatically»: the fastest server that connects (2px purple border, bolt circle). */
@Composable
private fun EmptyProfilesState(query: String, onAdd: () -> Unit)
private fun AutoServerCard(onClick: () -> Unit)
{
ShxCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
val c = LocalShxColors.current
val shape = RoundedCornerShape(24.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().clip(shape).background(c.accentSoft).border(2.dp, c.accent, shape)
.clickable(role = Role.Button, onClick = onClick).padding(horizontal = 16.dp, vertical = 14.dp),
)
{
if (query.isBlank())
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(CircleShape).background(c.accent))
{
ShxEmptyState(
icon = Icons.Rounded.Language,
message = stringResource(R.string.no_profiles),
actionLabel = stringResource(R.string.add_profile_cta),
onAction = onAdd,
)
Icon(ShxIcons.Bolt, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
}
else
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
ShxEmptyState(
icon = Icons.Rounded.SearchOff,
message = stringResource(R.string.nothing_found),
)
Text(stringResource(R.string.servers_auto), style = ShxType.rowTitle.copy(fontWeight = FontWeight.ExtraBold), color = c.text)
Text(stringResource(R.string.servers_auto_sub), style = ShxType.sub.copy(fontWeight = FontWeight.SemiBold), color = c.onAccentContainer)
}
}
}
/** Server row of the mockup: 68dp, code circle, name 16/700, status dot line, "more" button. */
@Composable
private fun ServerRow(profile: VpnProfile, active: Boolean, ping: ServerPing?, onClick: () -> Unit, onMore: () -> Unit)
{
val c = LocalShxColors.current
val code = org.strongswan.android.ui.compose.home.serverCode(profile, null)
val (dot, meta) = when
{
ping == null -> c.iconMuted to stringResource(R.string.servers_checking)
ping.rttMs == null -> c.warning to stringResource(R.string.servers_no_answer)
active -> c.success to stringResource(R.string.servers_connected_ms, ping.rttMs)
else -> c.success to stringResource(R.string.servers_available_ms, ping.rttMs)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().height(68.dp).clickable(role = Role.Button, onClick = onClick).padding(start = 14.dp, end = 8.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(CircleShape).background(if (active) c.accent else c.tile))
{
Text(code, 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, overflow = TextOverflow.Ellipsis)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp))
{
Box(Modifier.size(7.dp).clip(CircleShape).background(dot))
Text(meta, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, maxLines = 1)
}
}
Box(
contentAlignment = Alignment.Center,
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))
}
}
}
@Composable
private fun EmptyProfilesState(onAdd: () -> Unit)
{
ShxPanel()
{
ShxEmptyState(
icon = ShxIcons.Globe,
message = stringResource(R.string.no_profiles),
actionLabel = stringResource(R.string.add_profile_cta),
onAction = onAdd,
)
}
}
@@ -98,6 +98,27 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.widthIn
import org.strongswan.android.ui.compose.components.PredictiveBackScale
import org.strongswan.android.R
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp
import org.strongswan.android.logic.TrustedCertificateManager
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxPageHeader
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxScreenTitle
import org.strongswan.android.ui.compose.design.ShxSectionHeader
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.logic.StrongSwanApplication
import org.strongswan.android.logic.WatchdogReceiver
@@ -202,37 +223,24 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
Column(
Modifier
.fillMaxSize()
.background(LocalShxColors.current.background)
.statusBarsPadding()
.verticalScroll(rememberScrollState())
.padding(bottom = ShxSpacing.xl),
)
{
if (showTitle || page != SettingsPage.ROOT)
if (page != SettingsPage.ROOT)
{
TopAppBar(
title = {
Text(
stringResource(page.titleRes),
fontWeight = FontWeight.SemiBold,
modifier = Modifier.semantics { heading() },
)
},
navigationIcon = {
if (page != SettingsPage.ROOT)
{
IconButton(onClick = { pageState = SettingsPage.ROOT }) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(android.R.string.cancel))
}
}
},
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
)
ShxPageHeader(stringResource(page.titleRes), onBack = { pageState = SettingsPage.ROOT })
}
else if (showTitle)
{
ShxScreenTitle(stringResource(R.string.pref_title))
}
if (page == SettingsPage.ROOT)
{
SettingsRoot(onOpen = { pageState = it })
SettingsRoot(onOpen = { pageState = it }, updateState = updateState, versionName = versionName)
}
if (page == SettingsPage.APPEARANCE)
@@ -257,7 +265,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
}
}
if (page == SettingsPage.AUTOMATION)
if (page == SettingsPage.STABILITY)
{
ShxSection(title = stringResource(R.string.settings_section_general))
{
@@ -721,38 +729,191 @@ enum class SettingsPage(val titleRes: Int)
ROOT(R.string.pref_title),
APPEARANCE(R.string.settings_page_appearance),
AUTOMATION(R.string.settings_section_automation),
STABILITY(R.string.settings_stability),
SECURITY(R.string.settings_section_security),
UPDATES(R.string.settings_section_updates),
SUPPORT(R.string.settings_section_support),
}
/** Settings landing list: two quick rows on top, then the five groups. */
/** One row of the settings list (F_Settings): 62dp, 36dp grey icon well, 15/700 title, 12 sub. */
private data class SettingsEntry(
val title: String,
val sub: String,
val icon: androidx.compose.ui.graphics.vector.ImageVector,
val warn: Boolean = false,
val onClick: () -> Unit,
)
/** Settings landing list of the mockup: search, then the four groups. */
@Composable
private fun SettingsRoot(onOpen: (SettingsPage) -> Unit)
private fun SettingsRoot(onOpen: (SettingsPage) -> Unit, updateState: UpdateState, versionName: String)
{
val context = LocalContext.current
ShxSection(title = stringResource(R.string.settings_quick))
{
KillSwitchRow()
ShxSectionDivider()
ShxSettingRow(
icon = Icons.Rounded.Wifi,
title = stringResource(R.string.auto_connect_title),
subtitle = stringResource(R.string.auto_connect_summary),
onClick = { context.startActivity(Intent(context, TrustedNetworksActivity::class.java)) },
)
val c = LocalShxColors.current
var query by rememberSaveable { mutableStateOf("") }
/* re-read on every ON_RESUME: the kill switch and the trusted networks change outside */
var resumes by remember { mutableStateOf(0) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) resumes++ }
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
ShxSection(title = stringResource(R.string.pref_title))
val lockdown = remember(resumes) { context.isVpnLockdownEnabled() }
val trusted = remember(resumes) { org.strongswan.android.logic.autoconnect.AutoConnectManager.getInstance(context).currentConfig }
val health = remember(resumes) { org.strongswan.android.logic.connectivity.HealthCheckManager.getInstance(context).currentConfig }
val themeMode by ShxThemeSettings.themeMode.collectAsStateWithLifecycle()
var showKillSwitchDialog by remember { mutableStateOf(false) }
val certs = remember { runCatching { TrustedCertificateManager.getInstance().load().getAllCACertificates().size }.getOrDefault(0) }
val groups = listOf(
stringResource(R.string.settings_section_automation) to listOf(
SettingsEntry(
stringResource(R.string.settings_trusted_networks),
if (!trusted.enabled) stringResource(R.string.settings_off)
else trusted.trustedSsids.sorted().joinToString(", ").ifEmpty { stringResource(R.string.settings_trusted_none) },
ShxIcons.Wifi,
) { context.startActivity(Intent(context, TrustedNetworksActivity::class.java)) },
SettingsEntry(
stringResource(R.string.health_check_title),
if (health.enabled) stringResource(R.string.settings_health_sub, health.intervalSec) else stringResource(R.string.settings_off),
ShxIcons.Pulse,
) { context.startActivity(Intent(context, ConnectivityCheckActivity::class.java)) },
SettingsEntry(stringResource(R.string.settings_section_automation), stringResource(R.string.settings_automation_sub), ShxIcons.Bolt) { onOpen(SettingsPage.AUTOMATION) },
),
stringResource(R.string.settings_protection) to listOf(
SettingsEntry(
stringResource(R.string.settings_killswitch),
stringResource(if (lockdown) R.string.settings_killswitch_on else R.string.settings_killswitch_off),
ShxIcons.Lock,
warn = !lockdown,
) { if (lockdown) context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS)) else showKillSwitchDialog = true },
SettingsEntry(stringResource(R.string.settings_section_security), stringResource(R.string.settings_security_sub), ShxIcons.Shield) { onOpen(SettingsPage.SECURITY) },
),
stringResource(R.string.settings_app) to listOf(
SettingsEntry(stringResource(R.string.settings_stability), stringResource(R.string.settings_stability_sub), ShxIcons.Phone) { onOpen(SettingsPage.STABILITY) },
SettingsEntry(stringResource(R.string.settings_notifications), stringResource(R.string.settings_notifications_sub), ShxIcons.Bell) {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
else Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, android.net.Uri.fromParts("package", context.packageName, null))
runCatching { context.startActivity(intent) }
},
SettingsEntry(
stringResource(R.string.settings_section_updates),
if (updateState is UpdateState.Available) stringResource(R.string.settings_updates_available, (updateState as UpdateState.Available).info.versionName)
else stringResource(R.string.settings_updates_sub, versionName),
ShxIcons.Refresh,
warn = updateState is UpdateState.Available,
) { onOpen(SettingsPage.UPDATES) },
SettingsEntry(
stringResource(R.string.settings_page_appearance),
stringResource(
when (themeMode)
{
ThemeMode.SYSTEM -> R.string.theme_system
ThemeMode.LIGHT -> R.string.theme_light
ThemeMode.DARK -> R.string.theme_dark
}
),
ShxIcons.Moon,
) { onOpen(SettingsPage.APPEARANCE) },
),
stringResource(R.string.settings_section_support) to listOf(
SettingsEntry(stringResource(R.string.settings_log), stringResource(R.string.settings_log_sub), ShxIcons.Doc) { onOpen(SettingsPage.SUPPORT) },
SettingsEntry(
stringResource(R.string.trusted_certs_title),
pluralStringResource(R.plurals.settings_certs_n, certs, certs),
ShxIcons.Cert,
) { context.startActivity(Intent(context, TrustedCertificatesActivity::class.java)) },
),
)
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
ShxSettingRow(icon = Icons.Rounded.Palette, title = stringResource(R.string.settings_page_appearance), onClick = { onOpen(SettingsPage.APPEARANCE) })
ShxSectionDivider()
ShxSettingRow(icon = Icons.Rounded.Bolt, title = stringResource(R.string.settings_section_automation), onClick = { onOpen(SettingsPage.AUTOMATION) })
ShxSectionDivider()
ShxSettingRow(icon = Icons.Rounded.Security, title = stringResource(R.string.settings_section_security), onClick = { onOpen(SettingsPage.SECURITY) })
ShxSectionDivider()
ShxSettingRow(icon = Icons.Rounded.SystemUpdateAlt, title = stringResource(R.string.settings_section_updates), onClick = { onOpen(SettingsPage.UPDATES) })
ShxSectionDivider()
ShxSettingRow(icon = Icons.Rounded.Description, title = stringResource(R.string.settings_section_support), onClick = { onOpen(SettingsPage.SUPPORT) })
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(top = 4.dp).fillMaxWidth().height(48.dp).clip(RoundedCornerShape(24.dp)).background(c.tile).padding(horizontal = 16.dp),
)
{
Icon(ShxIcons.Search, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
Box(Modifier.weight(1f))
{
if (query.isEmpty())
{
Text(stringResource(R.string.settings_search), style = ShxType.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Medium), color = c.textSub)
}
BasicTextField(
value = query,
onValueChange = { query = it },
singleLine = true,
textStyle = ShxType.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Medium, color = c.text),
cursorBrush = SolidColor(c.accent),
modifier = Modifier.fillMaxWidth(),
)
}
}
groups.forEach { (title, rows) ->
val shown = rows.filter { query.isBlank() || it.title.contains(query.trim(), true) || it.sub.contains(query.trim(), true) }
if (shown.isNotEmpty())
{
Column(verticalArrangement = Arrangement.spacedBy(6.dp))
{
ShxSectionHeader(title)
ShxPanel()
{
shown.forEachIndexed { i, row ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 62.dp).clickable(onClick = row.onClick).padding(horizontal = 14.dp, vertical = 8.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(12.dp)).background(c.tile))
{
Icon(row.icon, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(18.dp))
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp))
{
Text(row.title, style = ShxType.cardTitle.copy(fontWeight = FontWeight.Bold), color = c.text)
Text(
row.sub,
style = ShxType.meta.copy(fontWeight = FontWeight.Normal),
color = if (row.warn) c.warning else c.textSub,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.iconMuted, modifier = Modifier.size(18.dp))
}
if (i < shown.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
}
}
}
}
}
}
if (showKillSwitchDialog)
{
AlertDialog(
onDismissRequest = { showKillSwitchDialog = false },
title = { Text(stringResource(R.string.kill_switch_dialog_title)) },
text = { Text(stringResource(R.string.kill_switch_dialog_text)) },
confirmButton = {
TextButton(onClick = {
showKillSwitchDialog = false
context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
}) { Text(stringResource(R.string.kill_switch_dialog_enable)) }
},
dismissButton = {
TextButton(onClick = { showKillSwitchDialog = false }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
}
@@ -220,6 +220,7 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
onRestore = viewModel::restore,
onShare = shareProfile,
onAdd = addProfile,
onFastest = { viewModel.connectFastest(context) },
)
ShxTab.ROUTING -> RoutingScreen()
ShxTab.SETTINGS -> SettingsScreen(profiles = state.profiles)
@@ -62,5 +62,12 @@ object ShxIcons
val WifiOff = line("wifiOff", "M2 8.8a15 15 0 0 1 4.4-2.7M10 5.1A15 15 0 0 1 22 8.8M5 12.9a10 10 0 0 1 4.6-2.6M14.6 10.5A10 10 0 0 1 19 12.9M8.5 16.4a5 5 0 0 1 7 0M12 20h.01M3 3l18 18", 2.2f)
val Key = line("key", "M15 7a4 4 0 1 0-3.9 5L4 19v2h3v-2h2v-2h2l1.9-1.9A4 4 0 0 0 15 7z", 2f)
val Info = line("info", "M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18M12 11v6M12 7.5v.5", 2.2f)
val Wifi = line("wifi", "M2 9a15 15 0 0 1 20 0M5 13a10 10 0 0 1 14 0M9 17a4 4 0 0 1 6 0", 2f)
val Lock = line("lock", "M6 11V8a6 6 0 0 1 12 0v3M5 11h14v10H5z", 2f)
val Phone = line("phone", "M7 4h10v17H7zM10 2h4M10 9h4M10 13h4", 2f)
val Moon = line("moon", "M12 3a9 9 0 1 0 9 9a7 7 0 0 1-9-9", 2f)
val Doc = line("doc", "M6 3h9l4 4v14H6zM9 12h6M9 16h6", 2f)
val Cert = line("cert", "M4 5h16v10H4zM8 19l4-2 4 2v-4H8z", 2f)
val Play = line("play", "M5 4l14 8-14 8z", 2f)
val Stats = line("stats", "M5 20V10M12 20V4M19 20v-7", 2.4f)
}
@@ -65,6 +65,25 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import org.strongswan.android.R
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxIntro
import org.strongswan.android.ui.compose.design.ShxPageHeader
import org.strongswan.android.ui.compose.design.ShxSwitch
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
@@ -89,7 +108,7 @@ fun DirectAppsScreen(onBack: () -> Unit, viewModel: DirectAppsViewModel = viewMo
)
}
/** Stateless body of [DirectAppsScreen] (previewable and testable). */
/** Stateless body of [DirectAppsScreen] (F_Apps). */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun DirectAppsContent(
@@ -103,237 +122,196 @@ fun DirectAppsContent(
onAllRf: () -> Unit,
)
{
SecondaryScaffold(title = stringResource(R.string.routing_apps_direct_title), onBack = onBack) { padding ->
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
top = padding.calculateTopPadding(),
bottom = padding.calculateBottomPadding() + ShxSpacing.xl,
),
)
{
item(key = "summary") {
Text(
text = pluralStringResource(R.plurals.routing_apps_direct_summary, state.directCount, state.directCount),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = ShxSpacing.lg + ShxSpacing.xs),
)
}
item(key = "search") {
SearchField(
query = state.query,
onQuery = onQuery,
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
)
}
item(key = "filters") {
Row(
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
)
val c = LocalShxColors.current
LazyColumn(
modifier = Modifier.fillMaxSize().background(c.background).statusBarsPadding(),
contentPadding = PaddingValues(bottom = 24.dp),
)
{
item(key = "header") { ShxPageHeader(stringResource(R.string.routing_apps_direct_title), onBack) }
item(key = "intro") {
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
ShxIntro(stringResource(R.string.apps_intro))
SearchField(state.query, onQuery)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
{
FilterTab(
label = stringResource(R.string.apps_filter_direct, state.directCount),
selected = state.filter == AppsFilter.DIRECT,
onClick = { onFilter(AppsFilter.DIRECT) },
)
FilterTab(
label = stringResource(R.string.apps_filter_vpn, state.tunnelCount),
selected = state.filter == AppsFilter.VPN,
onClick = { onFilter(AppsFilter.VPN) },
)
FilterTab(
label = stringResource(R.string.apps_filter_all),
selected = state.filter == AppsFilter.ALL,
onClick = { onFilter(AppsFilter.ALL) },
)
FilterTab(stringResource(R.string.apps_filter_direct, state.directCount), state.filter == AppsFilter.DIRECT) { onFilter(AppsFilter.DIRECT) }
FilterTab(stringResource(R.string.apps_filter_vpn, state.tunnelCount), state.filter == AppsFilter.VPN) { onFilter(AppsFilter.VPN) }
FilterTab(stringResource(R.string.apps_filter_all), state.filter == AppsFilter.ALL) { onFilter(AppsFilter.ALL) }
}
}
item(key = "quick") {
Column(Modifier.padding(top = ShxSpacing.lg)) {
Text(
text = stringResource(R.string.apps_quick_add),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = ShxSpacing.lg + ShxSpacing.xs, vertical = ShxSpacing.xs),
)
Row(
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(horizontal = ShxSpacing.lg),
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp))
{
Text(stringResource(R.string.apps_quick_add), style = ShxType.sub.copy(fontWeight = FontWeight.Bold), color = c.textSoft, modifier = Modifier.padding(horizontal = 4.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp))
{
state.quickChips.forEach { chip ->
QuickChip(
label = stringResource(categoryChipRes(chip.category)),
done = chip.done,
onClick = { onToggleCategory(chip.category, !chip.done) },
)
QuickChip(stringResource(categoryChipRes(chip.category)), chip.done) { onToggleCategory(chip.category, !chip.done) }
}
QuickChip(
label = stringResource(R.string.routing_apps_add_all_rf),
done = state.allRfDone,
onClick = onAllRf,
)
QuickChip(stringResource(R.string.routing_apps_add_all_rf), state.allRfDone, onAllRf)
}
}
}
if (state.loading)
{
item(key = "loading") {
Box(
Modifier
.fillMaxWidth()
.padding(ShxSpacing.xl),
contentAlignment = Alignment.Center,
)
{
LoadingIndicator(Modifier.size(40.dp))
}
}
}
else if (state.groups.isEmpty())
{
item(key = "empty") {
Text(
text = stringResource(R.string.apps_nothing_found),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 48.dp, horizontal = ShxSpacing.xl),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
else
{
state.groups.forEach { group ->
item(key = "h:${group.category}") {
Text(
text = stringResource(categoryTitleRes(group.category)).uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.padding(start = ShxSpacing.lg + ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm)
.semantics { heading() },
)
}
itemsIndexed(group.rows, key = { _, row -> "a:${row.packageName}" }) { index, row ->
AppRow(
row = row,
first = index == 0,
last = index == group.rows.lastIndex,
onToggle = { onToggleApp(row.packageName, it) },
)
}
}
}
item(key = "system") {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.toggleable(value = state.showSystem, role = Role.Checkbox, onValueChange = onShowSystem)
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.sm),
)
}
if (state.loading)
{
item(key = "loading") {
Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center)
{
Checkbox(checked = state.showSystem, onCheckedChange = null)
Text(
text = stringResource(R.string.apps_show_system),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = ShxSpacing.md),
)
LoadingIndicator(Modifier.size(40.dp))
}
}
}
else if (state.groups.isEmpty())
{
item(key = "empty") {
Text(
stringResource(R.string.apps_nothing_found),
style = ShxType.body,
color = c.textSub,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp, horizontal = 24.dp),
)
}
}
else
{
state.groups.forEach { group ->
item(key = "g:${group.category}") {
Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp), verticalArrangement = Arrangement.spacedBy(6.dp))
{
Text(
stringResource(categoryTitleRes(group.category)).uppercase(),
style = ShxType.section.copy(fontSize = 12.sp, letterSpacing = 0.06.em),
color = c.accentText,
modifier = Modifier.padding(start = 6.dp, top = 4.dp).semantics { heading() },
)
val shape = RoundedCornerShape(22.dp)
Column(Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape))
{
group.rows.forEachIndexed { i, row ->
AppRow(row) { onToggleApp(row.packageName, it) }
if (i < group.rows.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
}
}
}
}
}
}
item(key = "system") {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth()
.heightIn(min = 48.dp)
.toggleable(value = state.showSystem, role = Role.Checkbox, onValueChange = onShowSystem)
.padding(horizontal = 6.dp),
)
{
Checkbox(
checked = state.showSystem, onCheckedChange = null,
colors = androidx.compose.material3.CheckboxDefaults.colors(checkedColor = c.accent, uncheckedColor = c.outline),
)
Text(stringResource(R.string.apps_show_system), style = ShxType.body, color = c.textTonal)
}
}
}
}
/** Search pill of the mockup: 52dp, radius 26, grey. */
@Composable
private fun SearchField(query: String, onQuery: (String) -> Unit, modifier: Modifier = Modifier)
private fun SearchField(query: String, onQuery: (String) -> Unit)
{
TextField(
value = query,
onValueChange = onQuery,
singleLine = true,
placeholder = { Text(stringResource(R.string.apps_search)) },
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
trailingIcon = if (query.isNotEmpty())
{
{
IconButton(onClick = { onQuery("") }) {
Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.apps_search_clear))
}
}
}
else
{
null
},
shape = CircleShape,
colors = TextFieldDefaults.colors(
focusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
disabledIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
errorIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
modifier = modifier.fillMaxWidth(),
val c = LocalShxColors.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().height(52.dp).clip(RoundedCornerShape(26.dp)).background(c.tile).padding(horizontal = 16.dp),
)
{
Icon(ShxIcons.Search, contentDescription = null, tint = c.textSub, modifier = Modifier.size(20.dp))
Box(Modifier.weight(1f))
{
if (query.isEmpty())
{
Text(stringResource(R.string.apps_search), style = ShxType.body.copy(fontSize = 16.sp, fontWeight = FontWeight.Medium), color = c.textSub)
}
BasicTextField(
value = query,
onValueChange = onQuery,
singleLine = true,
textStyle = ShxType.body.copy(fontSize = 16.sp, fontWeight = FontWeight.Medium, color = c.text),
cursorBrush = SolidColor(c.accent),
modifier = Modifier.fillMaxWidth(),
)
}
if (query.isNotEmpty())
{
Icon(
ShxIcons.Close, contentDescription = stringResource(R.string.apps_search_clear), tint = c.textSub,
modifier = Modifier.size(20.dp).clip(CircleShape).clickable { onQuery("") },
)
}
}
}
/** Filter of the mockup: 36dp, radius 10; selected = #E4E0F9 with a check. */
@Composable
private fun FilterTab(label: String, selected: Boolean, onClick: () -> Unit)
{
FilterChip(
selected = selected,
onClick = onClick,
label = { Text(label, maxLines = 1) },
shape = MaterialTheme.shapes.small,
val c = LocalShxColors.current
val shape = RoundedCornerShape(10.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.height(36.dp)
.clip(shape)
.background(if (selected) c.accentMuted else Color.Transparent)
.then(if (selected) Modifier else Modifier.border(1.dp, c.outline, shape))
.selectable(selected = selected, role = Role.Tab, onClick = onClick)
.padding(horizontal = 12.dp),
)
{
if (selected)
{
Icon(ShxIcons.Check, contentDescription = null, tint = c.textTonal, modifier = Modifier.size(14.dp))
}
Text(label, style = ShxType.chip.copy(fontWeight = if (selected) FontWeight.Bold else FontWeight.SemiBold), color = if (selected) c.textTonal else c.text, maxLines = 1, softWrap = false)
}
}
/** Quick-add chip: 34dp, radius 10; done = "✓ label" on #E7DFFF, else outlined. */
@Composable
private fun QuickChip(label: String, done: Boolean, onClick: () -> Unit)
{
val status = LocalShxStatusColors.current
FilterChip(
selected = done,
onClick = onClick,
label = { Text(label, maxLines = 1) },
leadingIcon = {
Icon(
if (done) Icons.Rounded.Check else Icons.Rounded.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
},
shape = CircleShape,
colors = androidx.compose.material3.FilterChipDefaults.filterChipColors(
selectedContainerColor = status.connectedContainer,
selectedLabelColor = status.onConnectedContainer,
selectedLeadingIconColor = status.onConnectedContainer,
),
val c = LocalShxColors.current
val shape = RoundedCornerShape(10.dp)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.height(34.dp)
.clip(shape)
.background(if (done) c.accentContainer else c.card)
.then(if (done) Modifier else Modifier.border(1.dp, c.outline, shape))
.clickable(role = Role.Button, onClick = onClick)
.padding(horizontal = 12.dp),
)
{
Text(if (done) "✓ $label" else label, style = ShxType.chip, color = if (done) c.onAccentContainer else c.text, maxLines = 1, softWrap = false)
}
}
/** App row of the mockup: 36dp icon, name 15/700, reason 12 (green when direct), switch. */
@Composable
private fun AppRow(row: DirectAppRow, first: Boolean, last: Boolean, onToggle: (Boolean) -> Unit)
private fun AppRow(row: DirectAppRow, onToggle: (Boolean) -> Unit)
{
val status = LocalShxStatusColors.current
val corner = 20.dp
val shape = RoundedCornerShape(
topStart = if (first) corner else 0.dp,
topEnd = if (first) corner else 0.dp,
bottomStart = if (last) corner else 0.dp,
bottomEnd = if (last) corner else 0.dp,
)
val c = LocalShxColors.current
val reasonText = when (val reason = row.reason)
{
AppReason.Added -> stringResource(R.string.apps_reason_added)
@@ -343,52 +321,28 @@ private fun AppRow(row: DirectAppRow, first: Boolean, last: Boolean, onToggle: (
}
val reasonColor = when (row.reason)
{
is AppReason.Added, is AppReason.Template -> status.connected
is AppReason.Override -> MaterialTheme.colorScheme.tertiary
AppReason.Tunnel -> MaterialTheme.colorScheme.onSurfaceVariant
is AppReason.Added, is AppReason.Template -> c.onSuccess
is AppReason.Override -> c.onWarning
AppReason.Tunnel -> c.textSub
}
val switchDescription = stringResource(R.string.apps_switch_desc, row.label)
Surface(
shape = shape,
color = MaterialTheme.colorScheme.surfaceContainerLow,
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg),
.heightIn(min = 60.dp)
.toggleable(value = row.direct, role = Role.Switch, onValueChange = onToggle)
.semantics(mergeDescendants = true) { contentDescription = switchDescription }
.padding(horizontal = 14.dp, vertical = 10.dp),
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 64.dp)
.toggleable(value = row.direct, role = Role.Switch, onValueChange = onToggle)
.semantics(mergeDescendants = true) { contentDescription = switchDescription }
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
)
Box(Modifier.size(36.dp).clip(RoundedCornerShape(10.dp))) { AppIcon(row.packageName, 36.dp) }
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
AppIcon(row.packageName, 40.dp)
Column(
Modifier
.weight(1f)
.padding(horizontal = ShxSpacing.lg),
)
{
Text(
text = row.label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = reasonText,
style = MaterialTheme.typography.bodySmall,
color = reasonColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Switch(checked = row.direct, onCheckedChange = null)
Text(row.label, style = ShxType.cardTitle.copy(fontWeight = FontWeight.Bold), color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(reasonText, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = reasonColor, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
ShxSwitch(checked = row.direct, onCheckedChange = null)
}
}
@@ -861,4 +861,39 @@
<string name="recent_route_direct">напрямую</string>
<string name="recent_route_vpn">VPN</string>
<string name="events_read_all">Прочитать все</string>
<string name="servers_mine">Мои серверы · %1$d</string>
<string name="servers_note">Один сервер — один профиль .sswan.</string>
<string name="servers_auto">Автоматически</string>
<string name="servers_auto_sub">Самый быстрый, что подключается</string>
<string name="servers_checking">проверяем…</string>
<string name="servers_no_answer">не отвечает</string>
<string name="servers_connected_ms">подключён · %1$d мс</string>
<string name="servers_available_ms">доступен · %1$d мс</string>
<string name="settings_stability">Стабильная работа</string>
<string name="settings_stability_sub">Батарея, сервер по умолчанию, фон</string>
<string name="settings_trusted_networks">Доверенные сети</string>
<string name="settings_trusted_none">Включено, сетей пока нет</string>
<string name="settings_off">Выключено</string>
<string name="settings_health_sub">Каждые %1$d с · переподключать</string>
<string name="settings_automation_sub">Белые списки, ярлыки, сторож</string>
<string name="settings_protection">Защита</string>
<string name="settings_killswitch">Без VPN — без сети</string>
<string name="settings_killswitch_on">Включено · постоянный VPN</string>
<string name="settings_killswitch_off">Выключено — нажмите, чтобы включить</string>
<string name="settings_security_sub">IPv6, сертификаты, списки отзыва</string>
<string name="settings_app">Приложение</string>
<string name="settings_notifications">Уведомления</string>
<string name="settings_notifications_sub">Проблемы — важные, остальное — тихо</string>
<string name="settings_updates_sub">Версия %1$s</string>
<string name="settings_updates_available">Доступна версия %1$s</string>
<string name="settings_log">Журнал</string>
<string name="settings_log_sub">Для поддержки</string>
<string name="settings_search">Поиск по настройкам</string>
<plurals name="settings_certs_n">
<item quantity="one">%d доверенный сертификат</item>
<item quantity="few">%d доверенных сертификата</item>
<item quantity="many">%d доверенных сертификатов</item>
<item quantity="other">%d доверенных сертификата</item>
</plurals>
<string name="apps_intro">Включённые приложения ходят в сеть напрямую, мимо VPN. Банки и сервисы РФ часто не работают через VPN.</string>
</resources>
@@ -848,4 +848,37 @@
<string name="recent_route_direct">direct</string>
<string name="recent_route_vpn">VPN</string>
<string name="events_read_all">Read all</string>
<string name="servers_mine">My servers · %1$d</string>
<string name="servers_note">One server is one .sswan profile.</string>
<string name="servers_auto">Automatically</string>
<string name="servers_auto_sub">The fastest one that connects</string>
<string name="servers_checking">checking…</string>
<string name="servers_no_answer">no answer</string>
<string name="servers_connected_ms">connected · %1$d ms</string>
<string name="servers_available_ms">available · %1$d ms</string>
<string name="settings_stability">Stable work</string>
<string name="settings_stability_sub">Battery, default server, background</string>
<string name="settings_trusted_networks">Trusted networks</string>
<string name="settings_trusted_none">On, no networks yet</string>
<string name="settings_off">Off</string>
<string name="settings_health_sub">Every %1$d s · reconnect</string>
<string name="settings_automation_sub">Whitelists, shortcuts, watchdog</string>
<string name="settings_protection">Protection</string>
<string name="settings_killswitch">No VPN, no network</string>
<string name="settings_killswitch_on">On · always-on VPN</string>
<string name="settings_killswitch_off">Off — tap to turn on</string>
<string name="settings_security_sub">IPv6, certificates, CRL</string>
<string name="settings_app">App</string>
<string name="settings_notifications">Notifications</string>
<string name="settings_notifications_sub">Problems loud, the rest quiet</string>
<string name="settings_updates_sub">Version %1$s</string>
<string name="settings_updates_available">Version %1$s is available</string>
<string name="settings_log">Log</string>
<string name="settings_log_sub">For support</string>
<string name="settings_search">Search settings</string>
<plurals name="settings_certs_n">
<item quantity="one">%d trusted certificate</item>
<item quantity="other">%d trusted certificates</item>
</plurals>
<string name="apps_intro">Enabled apps reach the network directly, bypassing the VPN. Banks and RU services often do not work through a VPN.</string>
</resources>