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:
1 parent
4ff5ed11bb
commit
434e5bad4e
7 files changed
+688
-448
No files matched your search
+215
-166
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+206
-45
@@ -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)
|
||||
|
||||
+7
@@ -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)
|
||||
}
|
||||
+191
-237
@@ -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>
|
||||
Reference in new issue
Block a user