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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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.LaunchedEffect
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import org.strongswan.android.R 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.data.VpnProfile
import org.strongswan.android.logic.VpnStateService.State import org.strongswan.android.logic.VpnStateService.State
import org.strongswan.android.ui.compose.components.ShxCard import org.strongswan.android.ui.compose.components.ShxCard
@@ -92,11 +127,12 @@ fun ProfilesScreen(
onRestore: (VpnProfile) -> Unit, onRestore: (VpnProfile) -> Unit,
onShare: (VpnProfile) -> Unit, onShare: (VpnProfile) -> Unit,
onAdd: () -> Unit, onAdd: () -> Unit,
onFastest: () -> Unit = {},
) )
{ {
if (LocalConfiguration.current.screenWidthDp < 840) 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 return
} }
/* null = nothing open, "" = new profile, otherwise the edited profile's uuid */ /* 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 @Composable
private fun ProfilesList( private fun ProfilesList(
state: HomeUiState, state: HomeUiState,
@@ -154,21 +192,32 @@ private fun ProfilesList(
onRestore: (VpnProfile) -> Unit, onRestore: (VpnProfile) -> Unit,
onShare: (VpnProfile) -> Unit, onShare: (VpnProfile) -> Unit,
onAdd: () -> Unit, onAdd: () -> Unit,
onFastest: () -> Unit = {},
) )
{ {
val context = LocalContext.current val context = LocalContext.current
val c = LocalShxColors.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var query by remember { mutableStateOf("") }
var menuFor by remember { mutableStateOf<VpnProfile?>(null) } var menuFor by remember { mutableStateOf<VpnProfile?>(null) }
val activeUuid = state.activeProfile?.getUUID() val activeUuid = state.activeProfile?.getUUID()
val connected = state.vpnState == State.CONNECTED val connected = state.vpnState == State.CONNECTED
val profiles = state.profiles val profiles = state.profiles
val filtered = remember(query, profiles) /* direct pings of every server (outside the tunnel), refreshed while the tab is shown */
{ val lifecycle = androidx.lifecycle.compose.LocalLifecycleOwner.current.lifecycle
if (query.isBlank()) profiles val pings by produceState(emptyMap<String, ServerPing>(), profiles, lifecycle) {
else profiles.filter { lifecycle.repeatOnLifecycle(androidx.lifecycle.Lifecycle.State.STARTED) {
(it.name ?: "").contains(query, ignoreCase = true) || (it.gateway ?: "").contains(query, ignoreCase = true) 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( Box(Modifier.fillMaxSize().background(c.background))
snackbarHost = { ShxSnackbarHost(snackbarHostState) }, {
containerColor = MaterialTheme.colorScheme.background, Column(Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()))
) { _ ->
Column(Modifier.fillMaxSize())
{ {
TopAppBar( ShxScreenTitle(stringResource(R.string.nav_profiles))
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())
{ {
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( if (profiles.isEmpty())
shape = MaterialTheme.shapes.large, {
color = MaterialTheme.colorScheme.surfaceContainerLow, EmptyProfilesState(onAdd)
modifier = Modifier return@Column
.fillMaxWidth() }
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md), if (profiles.size > 1)
) { {
LazyColumn { AutoServerCard(onFastest)
itemsIndexed(filtered, key = { _, p -> p.getUUID().toString() }) { index, profile -> }
val isActive = connected && profile.getUUID() == activeUuid ShxSectionHeader(stringResource(R.string.servers_mine, profiles.size), Modifier.padding(top = 8.dp))
Column(Modifier.animateItem()) { ShxPanel()
Box { {
ShxProfileRow( profiles.forEachIndexed { index, profile ->
profile = profile, val isActive = connected && profile.getUUID() == activeUuid
active = isActive, ServerRow(
onClick = { if (isActive) onDisconnect() else onConnect(profile) }, profile = profile,
onLongClick = { menuFor = profile }, active = isActive,
trailing = { ping = pings[profile.getUUID().toString()],
IconButton(onClick = { menuFor = profile }) { onClick = { if (isActive) onDisconnect() else onConnect(profile) },
Icon( onMore = { menuFor = profile },
Icons.Rounded.MoreVert, )
contentDescription = stringResource(R.string.action_more_options), if (index < profiles.lastIndex)
tint = MaterialTheme.colorScheme.onSurfaceVariant, {
) Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
}
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()
}
}
} }
} }
} }
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 @Composable
private fun EmptyProfilesState(query: String, onAdd: () -> Unit) private fun AutoServerCard(onClick: () -> Unit)
{ {
ShxCard( val c = LocalShxColors.current
modifier = Modifier val shape = RoundedCornerShape(24.dp)
.fillMaxWidth() Row(
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md), 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(ShxIcons.Bolt, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
icon = Icons.Rounded.Language,
message = stringResource(R.string.no_profiles),
actionLabel = stringResource(R.string.add_profile_cta),
onAction = onAdd,
)
} }
else Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{ {
ShxEmptyState( Text(stringResource(R.string.servers_auto), style = ShxType.rowTitle.copy(fontWeight = FontWeight.ExtraBold), color = c.text)
icon = Icons.Rounded.SearchOff, Text(stringResource(R.string.servers_auto_sub), style = ShxType.sub.copy(fontWeight = FontWeight.SemiBold), color = c.onAccentContainer)
message = stringResource(R.string.nothing_found),
)
} }
} }
} }
/** 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 androidx.compose.foundation.layout.widthIn
import org.strongswan.android.ui.compose.components.PredictiveBackScale import org.strongswan.android.ui.compose.components.PredictiveBackScale
import org.strongswan.android.R 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.data.VpnProfile
import org.strongswan.android.logic.StrongSwanApplication import org.strongswan.android.logic.StrongSwanApplication
import org.strongswan.android.logic.WatchdogReceiver import org.strongswan.android.logic.WatchdogReceiver
@@ -202,37 +223,24 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
Column( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(LocalShxColors.current.background)
.statusBarsPadding()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(bottom = ShxSpacing.xl), .padding(bottom = ShxSpacing.xl),
) )
{ {
if (showTitle || page != SettingsPage.ROOT) if (page != SettingsPage.ROOT)
{ {
TopAppBar( ShxPageHeader(stringResource(page.titleRes), onBack = { pageState = SettingsPage.ROOT })
title = { }
Text( else if (showTitle)
stringResource(page.titleRes), {
fontWeight = FontWeight.SemiBold, ShxScreenTitle(stringResource(R.string.pref_title))
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,
),
)
} }
if (page == SettingsPage.ROOT) if (page == SettingsPage.ROOT)
{ {
SettingsRoot(onOpen = { pageState = it }) SettingsRoot(onOpen = { pageState = it }, updateState = updateState, versionName = versionName)
} }
if (page == SettingsPage.APPEARANCE) 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)) ShxSection(title = stringResource(R.string.settings_section_general))
{ {
@@ -721,38 +729,191 @@ enum class SettingsPage(val titleRes: Int)
ROOT(R.string.pref_title), ROOT(R.string.pref_title),
APPEARANCE(R.string.settings_page_appearance), APPEARANCE(R.string.settings_page_appearance),
AUTOMATION(R.string.settings_section_automation), AUTOMATION(R.string.settings_section_automation),
STABILITY(R.string.settings_stability),
SECURITY(R.string.settings_section_security), SECURITY(R.string.settings_section_security),
UPDATES(R.string.settings_section_updates), UPDATES(R.string.settings_section_updates),
SUPPORT(R.string.settings_section_support), 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 @Composable
private fun SettingsRoot(onOpen: (SettingsPage) -> Unit) private fun SettingsRoot(onOpen: (SettingsPage) -> Unit, updateState: UpdateState, versionName: String)
{ {
val context = LocalContext.current val context = LocalContext.current
ShxSection(title = stringResource(R.string.settings_quick)) val c = LocalShxColors.current
{ var query by rememberSaveable { mutableStateOf("") }
KillSwitchRow() /* re-read on every ON_RESUME: the kill switch and the trusted networks change outside */
ShxSectionDivider() var resumes by remember { mutableStateOf(0) }
ShxSettingRow( val lifecycleOwner = LocalLifecycleOwner.current
icon = Icons.Rounded.Wifi, DisposableEffect(lifecycleOwner) {
title = stringResource(R.string.auto_connect_title), val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) resumes++ }
subtitle = stringResource(R.string.auto_connect_summary), lifecycleOwner.lifecycle.addObserver(observer)
onClick = { context.startActivity(Intent(context, TrustedNetworksActivity::class.java)) }, 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) }) Row(
ShxSectionDivider() verticalAlignment = Alignment.CenterVertically,
ShxSettingRow(icon = Icons.Rounded.Bolt, title = stringResource(R.string.settings_section_automation), onClick = { onOpen(SettingsPage.AUTOMATION) }) horizontalArrangement = Arrangement.spacedBy(10.dp),
ShxSectionDivider() modifier = Modifier.padding(top = 4.dp).fillMaxWidth().height(48.dp).clip(RoundedCornerShape(24.dp)).background(c.tile).padding(horizontal = 16.dp),
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) }) Icon(ShxIcons.Search, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
ShxSectionDivider() Box(Modifier.weight(1f))
ShxSettingRow(icon = Icons.Rounded.Description, title = stringResource(R.string.settings_section_support), onClick = { onOpen(SettingsPage.SUPPORT) }) {
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, onRestore = viewModel::restore,
onShare = shareProfile, onShare = shareProfile,
onAdd = addProfile, onAdd = addProfile,
onFastest = { viewModel.connectFastest(context) },
) )
ShxTab.ROUTING -> RoutingScreen() ShxTab.ROUTING -> RoutingScreen()
ShxTab.SETTINGS -> SettingsScreen(profiles = state.profiles) 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 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 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 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) 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.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import org.strongswan.android.R 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.SecondaryScaffold
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing 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) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun DirectAppsContent( fun DirectAppsContent(
@@ -103,237 +122,196 @@ fun DirectAppsContent(
onAllRf: () -> Unit, onAllRf: () -> Unit,
) )
{ {
SecondaryScaffold(title = stringResource(R.string.routing_apps_direct_title), onBack = onBack) { padding -> val c = LocalShxColors.current
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize().background(c.background).statusBarsPadding(),
contentPadding = PaddingValues( contentPadding = PaddingValues(bottom = 24.dp),
top = padding.calculateTopPadding(), )
bottom = padding.calculateBottomPadding() + ShxSpacing.xl, {
), 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))
item(key = "summary") { {
Text( ShxIntro(stringResource(R.string.apps_intro))
text = pluralStringResource(R.plurals.routing_apps_direct_summary, state.directCount, state.directCount), SearchField(state.query, onQuery)
style = MaterialTheme.typography.bodyMedium, Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
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),
)
{ {
FilterTab( FilterTab(stringResource(R.string.apps_filter_direct, state.directCount), state.filter == AppsFilter.DIRECT) { onFilter(AppsFilter.DIRECT) }
label = stringResource(R.string.apps_filter_direct, state.directCount), FilterTab(stringResource(R.string.apps_filter_vpn, state.tunnelCount), state.filter == AppsFilter.VPN) { onFilter(AppsFilter.VPN) }
selected = state.filter == AppsFilter.DIRECT, FilterTab(stringResource(R.string.apps_filter_all), state.filter == AppsFilter.ALL) { onFilter(AppsFilter.ALL) }
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) },
)
} }
} Column(verticalArrangement = Arrangement.spacedBy(8.dp))
item(key = "quick") { {
Column(Modifier.padding(top = ShxSpacing.lg)) { Text(stringResource(R.string.apps_quick_add), style = ShxType.sub.copy(fontWeight = FontWeight.Bold), color = c.textSoft, modifier = Modifier.padding(horizontal = 4.dp))
Text( FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp))
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),
)
{ {
state.quickChips.forEach { chip -> state.quickChips.forEach { chip ->
QuickChip( QuickChip(stringResource(categoryChipRes(chip.category)), chip.done) { onToggleCategory(chip.category, !chip.done) }
label = stringResource(categoryChipRes(chip.category)),
done = chip.done,
onClick = { onToggleCategory(chip.category, !chip.done) },
)
} }
QuickChip( QuickChip(stringResource(R.string.routing_apps_add_all_rf), state.allRfDone, onAllRf)
label = stringResource(R.string.routing_apps_add_all_rf),
done = state.allRfDone,
onClick = onAllRf,
)
} }
} }
} }
if (state.loading) }
{ if (state.loading)
item(key = "loading") { {
Box( item(key = "loading") {
Modifier Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center)
.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),
)
{ {
Checkbox(checked = state.showSystem, onCheckedChange = null) LoadingIndicator(Modifier.size(40.dp))
Text(
text = stringResource(R.string.apps_show_system),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = ShxSpacing.md),
)
} }
} }
} }
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 @Composable
private fun SearchField(query: String, onQuery: (String) -> Unit, modifier: Modifier = Modifier) private fun SearchField(query: String, onQuery: (String) -> Unit)
{ {
TextField( val c = LocalShxColors.current
value = query, Row(
onValueChange = onQuery, verticalAlignment = Alignment.CenterVertically,
singleLine = true, horizontalArrangement = Arrangement.spacedBy(10.dp),
placeholder = { Text(stringResource(R.string.apps_search)) }, modifier = Modifier.fillMaxWidth().height(52.dp).clip(RoundedCornerShape(26.dp)).background(c.tile).padding(horizontal = 16.dp),
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(),
) )
{
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 @Composable
private fun FilterTab(label: String, selected: Boolean, onClick: () -> Unit) private fun FilterTab(label: String, selected: Boolean, onClick: () -> Unit)
{ {
FilterChip( val c = LocalShxColors.current
selected = selected, val shape = RoundedCornerShape(10.dp)
onClick = onClick, Row(
label = { Text(label, maxLines = 1) }, verticalAlignment = Alignment.CenterVertically,
shape = MaterialTheme.shapes.small, 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 @Composable
private fun QuickChip(label: String, done: Boolean, onClick: () -> Unit) private fun QuickChip(label: String, done: Boolean, onClick: () -> Unit)
{ {
val status = LocalShxStatusColors.current val c = LocalShxColors.current
FilterChip( val shape = RoundedCornerShape(10.dp)
selected = done, Box(
onClick = onClick, contentAlignment = Alignment.Center,
label = { Text(label, maxLines = 1) }, modifier = Modifier
leadingIcon = { .height(34.dp)
Icon( .clip(shape)
if (done) Icons.Rounded.Check else Icons.Rounded.Add, .background(if (done) c.accentContainer else c.card)
contentDescription = null, .then(if (done) Modifier else Modifier.border(1.dp, c.outline, shape))
modifier = Modifier.size(18.dp), .clickable(role = Role.Button, onClick = onClick)
) .padding(horizontal = 12.dp),
},
shape = CircleShape,
colors = androidx.compose.material3.FilterChipDefaults.filterChipColors(
selectedContainerColor = status.connectedContainer,
selectedLabelColor = status.onConnectedContainer,
selectedLeadingIconColor = status.onConnectedContainer,
),
) )
{
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 @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 c = LocalShxColors.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 reasonText = when (val reason = row.reason) val reasonText = when (val reason = row.reason)
{ {
AppReason.Added -> stringResource(R.string.apps_reason_added) 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) val reasonColor = when (row.reason)
{ {
is AppReason.Added, is AppReason.Template -> status.connected is AppReason.Added, is AppReason.Template -> c.onSuccess
is AppReason.Override -> MaterialTheme.colorScheme.tertiary is AppReason.Override -> c.onWarning
AppReason.Tunnel -> MaterialTheme.colorScheme.onSurfaceVariant AppReason.Tunnel -> c.textSub
} }
val switchDescription = stringResource(R.string.apps_switch_desc, row.label) val switchDescription = stringResource(R.string.apps_switch_desc, row.label)
Surface( Row(
shape = shape, verticalAlignment = Alignment.CenterVertically,
color = MaterialTheme.colorScheme.surfaceContainerLow, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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( Box(Modifier.size(36.dp).clip(RoundedCornerShape(10.dp))) { AppIcon(row.packageName, 36.dp) }
verticalAlignment = Alignment.CenterVertically, Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
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),
)
{ {
AppIcon(row.packageName, 40.dp) Text(row.label, style = ShxType.cardTitle.copy(fontWeight = FontWeight.Bold), color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
Column( Text(reasonText, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = reasonColor, maxLines = 2, overflow = TextOverflow.Ellipsis)
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)
} }
ShxSwitch(checked = row.direct, onCheckedChange = null)
} }
} }
@@ -861,4 +861,39 @@
<string name="recent_route_direct">напрямую</string> <string name="recent_route_direct">напрямую</string>
<string name="recent_route_vpn">VPN</string> <string name="recent_route_vpn">VPN</string>
<string name="events_read_all">Прочитать все</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> </resources>
@@ -848,4 +848,37 @@
<string name="recent_route_direct">direct</string> <string name="recent_route_direct">direct</string>
<string name="recent_route_vpn">VPN</string> <string name="recent_route_vpn">VPN</string>
<string name="events_read_all">Read all</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> </resources>