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.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.R
|
||||||
import org.strongswan.android.data.VpnProfile
|
import org.strongswan.android.data.VpnProfile
|
||||||
import org.strongswan.android.logic.StrongSwanApplication
|
import org.strongswan.android.logic.StrongSwanApplication
|
||||||
@@ -124,6 +150,8 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
val updateState by updateManager.state.collectAsStateWithLifecycle()
|
val updateState by updateManager.state.collectAsStateWithLifecycle()
|
||||||
var showUpdateConfirm by remember { mutableStateOf(false) }
|
var showUpdateConfirm by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val unknownSourcesLauncher = rememberLauncherForActivityResult(
|
val unknownSourcesLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.StartActivityForResult()
|
ActivityResultContracts.StartActivityForResult()
|
||||||
) {
|
) {
|
||||||
@@ -133,7 +161,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
}
|
}
|
||||||
else
|
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 = {
|
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(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(bottom = 24.dp),
|
.padding(bottom = ShxSpacing.xl),
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (showTitle)
|
if (showTitle || page != SettingsPage.ROOT)
|
||||||
{
|
{
|
||||||
TopAppBar(
|
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(
|
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
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
|
var addedRow = false
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||||
{
|
{
|
||||||
@@ -182,6 +249,14 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
)
|
)
|
||||||
addedRow = 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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && managed.isAllowSettingsAccess)
|
||||||
{
|
{
|
||||||
if (addedRow)
|
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))
|
ShxSection(title = stringResource(R.string.settings_section_automation))
|
||||||
{
|
{
|
||||||
ShxSettingRow(
|
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))
|
ShxSection(title = stringResource(R.string.settings_section_security))
|
||||||
{
|
{
|
||||||
/* re-read on every ON_RESUME so the summary reflects changes made in system settings */
|
KillSwitchRow()
|
||||||
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
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ShxSectionDivider()
|
ShxSectionDivider()
|
||||||
var safeIpv6 by remember {
|
var safeIpv6 by remember {
|
||||||
mutableStateOf(prefs.getBoolean(Constants.PREF_SAFE_IPV6, false))
|
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))
|
ShxSection(title = stringResource(R.string.settings_section_certs))
|
||||||
{
|
{
|
||||||
ShxSettingRow(
|
ShxSettingRow(
|
||||||
@@ -366,7 +405,10 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
onClick = { showCrl = true },
|
onClick = { showCrl = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page == SettingsPage.SUPPORT)
|
||||||
|
{
|
||||||
if (managed.isAllowProfileImport)
|
if (managed.isAllowProfileImport)
|
||||||
{
|
{
|
||||||
ShxSection(title = stringResource(R.string.settings_section_profiles))
|
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))
|
ShxSection(title = stringResource(R.string.settings_section_support))
|
||||||
{
|
{
|
||||||
ShxSettingRow(
|
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))
|
ShxSection(title = stringResource(R.string.settings_section_updates))
|
||||||
{
|
{
|
||||||
var autoUpdate by remember {
|
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),
|
text = stringResource(R.string.app_version_fmt, versionName),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
@@ -481,6 +530,12 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
ShxSnackbarHost(
|
||||||
|
hostState = snackbarHostState,
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showDefaultPicker)
|
if (showDefaultPicker)
|
||||||
{
|
{
|
||||||
@@ -534,7 +589,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
if (files.isEmpty())
|
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
|
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
|
package org.strongswan.android.ui.compose.theme
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -27,6 +28,9 @@ import androidx.compose.material3.lightColorScheme
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.Immutable
|
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.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
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
|
* Process-wide holder of the "use system colors" preference, so flipping it in
|
||||||
* Settings re-themes every open Compose surface without recreating activities.
|
* Settings re-themes every open Compose surface without recreating activities.
|
||||||
*/
|
*/
|
||||||
|
enum class ThemeMode
|
||||||
|
{
|
||||||
|
SYSTEM,
|
||||||
|
LIGHT,
|
||||||
|
DARK,
|
||||||
|
}
|
||||||
|
|
||||||
object ShxThemeSettings
|
object ShxThemeSettings
|
||||||
{
|
{
|
||||||
private const val KEY_DYNAMIC_COLORS = "pref_use_dynamic_colors"
|
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)
|
private val _useDynamicColors = MutableStateFlow(false)
|
||||||
|
|
||||||
@@ -227,10 +243,18 @@ object ShxThemeSettings
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_useDynamicColors.value = prefs(context).getBoolean(KEY_DYNAMIC_COLORS, false)
|
_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
|
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)
|
fun setUseDynamicColors(context: Context, enabled: Boolean)
|
||||||
{
|
{
|
||||||
load(context)
|
load(context)
|
||||||
@@ -268,9 +292,31 @@ private val ShxTypography = buildTypography()
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ShxTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit)
|
fun ShxTheme(content: @Composable () -> Unit)
|
||||||
{
|
{
|
||||||
val context = LocalContext.current
|
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 }
|
val useDynamicColors by remember { ShxThemeSettings.load(context); ShxThemeSettings.useDynamicColors }
|
||||||
.collectAsStateWithLifecycle()
|
.collectAsStateWithLifecycle()
|
||||||
val colors = when
|
val colors = when
|
||||||
|
|||||||
@@ -493,4 +493,10 @@
|
|||||||
<string name="availability_collapse">Скрыть детали</string>
|
<string name="availability_collapse">Скрыть детали</string>
|
||||||
<string name="routing_advanced">Дополнительно</string>
|
<string name="routing_advanced">Дополнительно</string>
|
||||||
<string name="routing_killswitch_note">Исключённые приложения (%1$d) всегда идут мимо VPN, в том числе при обрыве туннеля: kill switch их не блокирует.</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>
|
</resources>
|
||||||
@@ -498,4 +498,10 @@
|
|||||||
<string name="availability_collapse">Hide details</string>
|
<string name="availability_collapse">Hide details</string>
|
||||||
<string name="routing_advanced">Advanced</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="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>
|
</resources>
|
||||||
Reference in new issue
Block a user