android: settings as grouped pages (appearance, automation, security, updates, support), theme mode, snackbars instead of toasts
This commit is contained in:
1 parent
a451386e0a
commit
0c3ea75cac
4 files changed
+289
-54
No files matched your search
+230
-53
@@ -69,6 +69,32 @@ import androidx.preference.PreferenceManager
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.Brightness6
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import kotlinx.coroutines.launch
|
||||
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
|
||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||
import org.strongswan.android.ui.compose.theme.ThemeMode
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.VpnProfile
|
||||
import org.strongswan.android.logic.StrongSwanApplication
|
||||
@@ -124,6 +150,8 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
val updateState by updateManager.state.collectAsStateWithLifecycle()
|
||||
var showUpdateConfirm by remember { mutableStateOf(false) }
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val unknownSourcesLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
@@ -133,7 +161,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
}
|
||||
else
|
||||
{
|
||||
Toast.makeText(context, R.string.update_permission_denied, Toast.LENGTH_LONG).show()
|
||||
scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.update_permission_denied)) }
|
||||
}
|
||||
}
|
||||
val startUpdate: () -> Unit = {
|
||||
@@ -147,25 +175,64 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
}
|
||||
}
|
||||
|
||||
var pageState by rememberSaveable { mutableStateOf(SettingsPage.ROOT) }
|
||||
/* from a sub page "back" returns to the settings list, not out of the tab */
|
||||
BackHandler(enabled = pageState != SettingsPage.ROOT) { pageState = SettingsPage.ROOT }
|
||||
val effects = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
|
||||
val spatial = MaterialTheme.motionScheme.defaultSpatialSpec<androidx.compose.ui.unit.IntOffset>()
|
||||
|
||||
Box(Modifier.fillMaxSize())
|
||||
{
|
||||
AnimatedContent(
|
||||
targetState = pageState,
|
||||
transitionSpec = {
|
||||
val forward = targetState != SettingsPage.ROOT
|
||||
(fadeIn(effects) + slideInHorizontally(spatial) { if (forward) it / 8 else -it / 8 }) togetherWith
|
||||
(fadeOut(effects) + slideOutHorizontally(spatial) { if (forward) -it / 8 else it / 8 })
|
||||
},
|
||||
label = "settingsPage",
|
||||
) { page ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 24.dp),
|
||||
.padding(bottom = ShxSpacing.xl),
|
||||
)
|
||||
{
|
||||
if (showTitle)
|
||||
if (showTitle || page != SettingsPage.ROOT)
|
||||
{
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.pref_title), fontWeight = FontWeight.SemiBold) },
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
ShxSection(title = stringResource(R.string.settings_section_general))
|
||||
if (page == SettingsPage.ROOT)
|
||||
{
|
||||
SettingsRoot(onOpen = { pageState = it })
|
||||
}
|
||||
|
||||
if (page == SettingsPage.APPEARANCE)
|
||||
{
|
||||
ShxSection(title = stringResource(R.string.settings_page_appearance))
|
||||
{
|
||||
ThemeModeRow()
|
||||
var addedRow = false
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
{
|
||||
@@ -182,6 +249,14 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
)
|
||||
addedRow = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (page == SettingsPage.AUTOMATION)
|
||||
{
|
||||
ShxSection(title = stringResource(R.string.settings_section_general))
|
||||
{
|
||||
var addedRow = false
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && managed.isAllowSettingsAccess)
|
||||
{
|
||||
if (addedRow)
|
||||
@@ -218,7 +293,10 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (page == SettingsPage.AUTOMATION)
|
||||
{
|
||||
ShxSection(title = stringResource(R.string.settings_section_automation))
|
||||
{
|
||||
ShxSettingRow(
|
||||
@@ -285,55 +363,13 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (page == SettingsPage.SECURITY)
|
||||
{
|
||||
ShxSection(title = stringResource(R.string.settings_section_security))
|
||||
{
|
||||
/* re-read on every ON_RESUME so the summary reflects changes made in system settings */
|
||||
var lockdownActive by remember { mutableStateOf(context.isVpnLockdownEnabled()) }
|
||||
var showKillSwitchDialog by remember { mutableStateOf(false) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME)
|
||||
{
|
||||
lockdownActive = context.isVpnLockdownEnabled()
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
ShxSettingRow(
|
||||
icon = Icons.Rounded.Security,
|
||||
title = stringResource(R.string.kill_switch_title),
|
||||
subtitle = stringResource(
|
||||
if (lockdownActive) R.string.kill_switch_summary_on
|
||||
else R.string.kill_switch_summary_off
|
||||
),
|
||||
onClick = {
|
||||
/* explain what this actually toggles before sending the user to system settings */
|
||||
if (lockdownActive) context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
|
||||
else showKillSwitchDialog = true
|
||||
},
|
||||
)
|
||||
KillSwitchRow()
|
||||
ShxSectionDivider()
|
||||
var safeIpv6 by remember {
|
||||
mutableStateOf(prefs.getBoolean(Constants.PREF_SAFE_IPV6, false))
|
||||
@@ -349,7 +385,10 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (page == SettingsPage.SECURITY)
|
||||
{
|
||||
ShxSection(title = stringResource(R.string.settings_section_certs))
|
||||
{
|
||||
ShxSettingRow(
|
||||
@@ -366,7 +405,10 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
onClick = { showCrl = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (page == SettingsPage.SUPPORT)
|
||||
{
|
||||
if (managed.isAllowProfileImport)
|
||||
{
|
||||
ShxSection(title = stringResource(R.string.settings_section_profiles))
|
||||
@@ -380,7 +422,10 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (page == SettingsPage.SUPPORT)
|
||||
{
|
||||
ShxSection(title = stringResource(R.string.settings_section_support))
|
||||
{
|
||||
ShxSettingRow(
|
||||
@@ -405,7 +450,10 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (page == SettingsPage.UPDATES)
|
||||
{
|
||||
ShxSection(title = stringResource(R.string.settings_section_updates))
|
||||
{
|
||||
var autoUpdate by remember {
|
||||
@@ -470,8 +518,9 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
if (page == SettingsPage.ROOT || page == SettingsPage.UPDATES) Text(
|
||||
text = stringResource(R.string.app_version_fmt, versionName),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -481,6 +530,12 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
ShxSnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
}
|
||||
|
||||
if (showDefaultPicker)
|
||||
{
|
||||
@@ -534,7 +589,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
TextButton(onClick = {
|
||||
if (files.isEmpty())
|
||||
{
|
||||
Toast.makeText(context, R.string.clear_crl_cache_msg_none, Toast.LENGTH_SHORT).show()
|
||||
scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.clear_crl_cache_msg_none)) }
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -654,3 +709,125 @@ private fun openSamsungBatterySettings(context: Context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class SettingsPage(val titleRes: Int)
|
||||
{
|
||||
ROOT(R.string.pref_title),
|
||||
APPEARANCE(R.string.settings_page_appearance),
|
||||
AUTOMATION(R.string.settings_section_automation),
|
||||
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. */
|
||||
@Composable
|
||||
private fun SettingsRoot(onOpen: (SettingsPage) -> Unit)
|
||||
{
|
||||
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)) },
|
||||
)
|
||||
}
|
||||
ShxSection(title = stringResource(R.string.pref_title))
|
||||
{
|
||||
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) })
|
||||
}
|
||||
}
|
||||
|
||||
/** System / light / dark selector, applied immediately through [ShxThemeSettings]. */
|
||||
@Composable
|
||||
private fun ThemeModeRow()
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val mode by ShxThemeSettings.themeMode.collectAsStateWithLifecycle()
|
||||
remember { ShxThemeSettings.load(context) }
|
||||
Column(Modifier.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md))
|
||||
{
|
||||
Text(stringResource(R.string.pref_theme_title), style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(Modifier.height(ShxSpacing.sm))
|
||||
val options = listOf(
|
||||
ThemeMode.SYSTEM to R.string.theme_system,
|
||||
ThemeMode.LIGHT to R.string.theme_light,
|
||||
ThemeMode.DARK to R.string.theme_dark,
|
||||
)
|
||||
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth())
|
||||
{
|
||||
options.forEachIndexed { index, (value, label) ->
|
||||
SegmentedButton(
|
||||
selected = mode == value,
|
||||
onClick = { ShxThemeSettings.setThemeMode(context, value) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index, options.size),
|
||||
) { Text(stringResource(label)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Kill switch status row; explains and opens the system "Always-on VPN" settings. */
|
||||
@Composable
|
||||
private fun KillSwitchRow()
|
||||
{
|
||||
val context = LocalContext.current
|
||||
/* re-read on every ON_RESUME so the summary reflects changes made in system settings */
|
||||
var lockdownActive by remember { mutableStateOf(context.isVpnLockdownEnabled()) }
|
||||
var showKillSwitchDialog by remember { mutableStateOf(false) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME)
|
||||
{
|
||||
lockdownActive = context.isVpnLockdownEnabled()
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
ShxSettingRow(
|
||||
icon = Icons.Rounded.Security,
|
||||
title = stringResource(R.string.kill_switch_title),
|
||||
subtitle = stringResource(
|
||||
if (lockdownActive) R.string.kill_switch_summary_on
|
||||
else R.string.kill_switch_summary_off
|
||||
),
|
||||
onClick = {
|
||||
/* explain what this actually toggles before sending the user to system settings */
|
||||
if (lockdownActive) context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
|
||||
else showKillSwitchDialog = true
|
||||
},
|
||||
)
|
||||
}
|
||||
+47
-1
@@ -9,6 +9,7 @@
|
||||
|
||||
package org.strongswan.android.ui.compose.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
@@ -27,6 +28,9 @@ import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
@@ -205,9 +209,21 @@ val LocalShxStatusColors = staticCompositionLocalOf { LightStatusColors }
|
||||
* Process-wide holder of the "use system colors" preference, so flipping it in
|
||||
* Settings re-themes every open Compose surface without recreating activities.
|
||||
*/
|
||||
enum class ThemeMode
|
||||
{
|
||||
SYSTEM,
|
||||
LIGHT,
|
||||
DARK,
|
||||
}
|
||||
|
||||
object ShxThemeSettings
|
||||
{
|
||||
private const val KEY_DYNAMIC_COLORS = "pref_use_dynamic_colors"
|
||||
private const val KEY_THEME_MODE = "pref_theme_mode"
|
||||
|
||||
private val _themeMode = MutableStateFlow(ThemeMode.SYSTEM)
|
||||
|
||||
val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()
|
||||
|
||||
private val _useDynamicColors = MutableStateFlow(false)
|
||||
|
||||
@@ -227,10 +243,18 @@ object ShxThemeSettings
|
||||
return
|
||||
}
|
||||
_useDynamicColors.value = prefs(context).getBoolean(KEY_DYNAMIC_COLORS, false)
|
||||
_themeMode.value = ThemeMode.entries.getOrElse(prefs(context).getInt(KEY_THEME_MODE, 0)) { ThemeMode.SYSTEM }
|
||||
loaded = true
|
||||
}
|
||||
}
|
||||
|
||||
fun setThemeMode(context: Context, mode: ThemeMode)
|
||||
{
|
||||
load(context)
|
||||
prefs(context).edit().putInt(KEY_THEME_MODE, mode.ordinal).apply()
|
||||
_themeMode.value = mode
|
||||
}
|
||||
|
||||
fun setUseDynamicColors(context: Context, enabled: Boolean)
|
||||
{
|
||||
load(context)
|
||||
@@ -268,9 +292,31 @@ private val ShxTypography = buildTypography()
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ShxTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit)
|
||||
fun ShxTheme(content: @Composable () -> Unit)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val themeMode by remember { ShxThemeSettings.load(context); ShxThemeSettings.themeMode }
|
||||
.collectAsStateWithLifecycle()
|
||||
val darkTheme = when (themeMode)
|
||||
{
|
||||
ThemeMode.LIGHT -> false
|
||||
ThemeMode.DARK -> true
|
||||
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||
}
|
||||
/* keep system bar icons readable when the chosen theme differs from the system one */
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode)
|
||||
{
|
||||
SideEffect {
|
||||
val window = (view.context as? Activity)?.window
|
||||
if (window != null)
|
||||
{
|
||||
val controller = WindowCompat.getInsetsController(window, view)
|
||||
controller.isAppearanceLightStatusBars = !darkTheme
|
||||
controller.isAppearanceLightNavigationBars = !darkTheme
|
||||
}
|
||||
}
|
||||
}
|
||||
val useDynamicColors by remember { ShxThemeSettings.load(context); ShxThemeSettings.useDynamicColors }
|
||||
.collectAsStateWithLifecycle()
|
||||
val colors = when
|
||||
|
||||
@@ -493,4 +493,10 @@
|
||||
<string name="availability_collapse">Скрыть детали</string>
|
||||
<string name="routing_advanced">Дополнительно</string>
|
||||
<string name="routing_killswitch_note">Исключённые приложения (%1$d) всегда идут мимо VPN, в том числе при обрыве туннеля: kill switch их не блокирует.</string>
|
||||
<string name="settings_page_appearance">Оформление</string>
|
||||
<string name="settings_quick">Быстрые настройки</string>
|
||||
<string name="pref_theme_title">Тема</string>
|
||||
<string name="theme_system">Системная</string>
|
||||
<string name="theme_light">Светлая</string>
|
||||
<string name="theme_dark">Тёмная</string>
|
||||
</resources>
|
||||
@@ -498,4 +498,10 @@
|
||||
<string name="availability_collapse">Hide details</string>
|
||||
<string name="routing_advanced">Advanced</string>
|
||||
<string name="routing_killswitch_note">%1$d excluded app(s) always use the normal network, even when the tunnel drops: the kill switch does not block them.</string>
|
||||
<string name="settings_page_appearance">Appearance</string>
|
||||
<string name="settings_quick">Quick settings</string>
|
||||
<string name="pref_theme_title">Theme</string>
|
||||
<string name="theme_system">System</string>
|
||||
<string name="theme_light">Light</string>
|
||||
<string name="theme_dark">Dark</string>
|
||||
</resources>
|
||||
Reference in new issue
Block a user