android: appearance, kill switch and update pages as in the Final 2026 mockup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-12 10:04:12 +07:00
1 parent fecef1135c
commit dc8f5a75e1
4 files changed
+384 -20

No files matched your search

@@ -0,0 +1,302 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*/
package org.strongswan.android.ui.compose
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.strongswan.android.R
import org.strongswan.android.logic.updates.UpdateState
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.ShxNote
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxSectionHeader
import org.strongswan.android.ui.compose.design.ShxToggleRow
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxThemeSettings
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.ui.compose.theme.ThemeMode
/** «Appearance» (F_Appearance): three theme cards with a preview, wallpaper colors, language. */
@Composable
fun AppearancePage()
{
val context = LocalContext.current
val c = LocalShxColors.current
val mode by remember { ShxThemeSettings.load(context); ShxThemeSettings.themeMode }.collectAsStateWithLifecycle()
val dynamic by remember { ShxThemeSettings.useDynamicColors }.collectAsStateWithLifecycle()
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
ShxSectionHeader(stringResource(R.string.appearance_theme))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
listOf(ThemeMode.SYSTEM to R.string.theme_system, ThemeMode.LIGHT to R.string.theme_light, ThemeMode.DARK to R.string.theme_dark).forEach { (m, label) ->
val on = mode == m
val shape = RoundedCornerShape(20.dp)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.weight(1f)
.clip(shape)
.background(c.card)
.border(if (on) 2.dp else 1.dp, if (on) c.accent else c.cardBorder, shape)
.selectable(selected = on, role = Role.RadioButton) { ShxThemeSettings.setThemeMode(context, m) }
.padding(10.dp),
)
{
ThemePreview(m)
Text(stringResource(label), style = ShxType.chip, color = c.text, maxLines = 1)
}
}
}
ShxPanel(Modifier.padding(top = 4.dp))
{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
{
ShxToggleRow(
stringResource(R.string.appearance_wallpaper),
dynamic,
{ ShxThemeSettings.setUseDynamicColors(context, it) },
subtitle = stringResource(R.string.appearance_wallpaper_sub),
divider = true,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().height(60.dp)
.clickable(role = Role.Button) {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
Intent(Settings.ACTION_APP_LOCALE_SETTINGS, Uri.fromParts("package", context.packageName, null))
else Intent(Settings.ACTION_LOCALE_SETTINGS)
runCatching { context.startActivity(intent) }
}
.padding(start = 16.dp, end = 14.dp),
)
{
Text(stringResource(R.string.appearance_language), style = ShxType.cardTitle.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f))
Text(stringResource(R.string.theme_system), style = ShxType.body, color = c.textSub)
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.iconMuted, modifier = Modifier.size(18.dp))
}
}
}
}
/** Mini screen of a theme: background, the hero blob, two text lines. */
@Composable
private fun ThemePreview(mode: ThemeMode)
{
val light = Color(0xFFFCF8FF)
val dark = Color(0xFF141318)
val bg = when (mode)
{
ThemeMode.SYSTEM -> Brush.horizontalGradient(0f to light, 0.5f to light, 0.5f to dark, 1f to dark)
ThemeMode.LIGHT -> Brush.linearGradient(listOf(light, light))
ThemeMode.DARK -> Brush.linearGradient(listOf(dark, dark))
}
val accent = if (mode == ThemeMode.DARK) Color(0xFF9C7CFF) else Color(0xFF6D4AFF)
val line = when (mode)
{
ThemeMode.SYSTEM -> Color(0xFFC9C5D0)
ThemeMode.LIGHT -> Color(0xFFE5E1E9)
ThemeMode.DARK -> Color(0xFF36333D)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
modifier = Modifier.fillMaxWidth().height(120.dp).clip(RoundedCornerShape(14.dp)).background(bg).border(1.dp, Color(0xFFE5E1E9), RoundedCornerShape(14.dp)),
)
{
Box(Modifier.size(34.dp).clip(RoundedCornerShape(topStartPercent = 45, topEndPercent = 55, bottomEndPercent = 50, bottomStartPercent = 50)).background(accent))
Box(Modifier.width(56.dp).height(8.dp).clip(RoundedCornerShape(4.dp)).background(line))
Box(Modifier.width(40.dp).height(8.dp).clip(RoundedCornerShape(4.dp)).background(line))
}
}
/** «No VPN, no network» (F_KillSwitch): status, three steps, note, button to the Android settings. */
@Composable
fun KillSwitchPage(lockdown: Boolean)
{
val context = LocalContext.current
val c = LocalShxColors.current
Column(Modifier.padding(horizontal = 16.dp).padding(top = 4.dp), verticalArrangement = Arrangement.spacedBy(14.dp))
{
val shape = RoundedCornerShape(24.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(shape)
.background(if (lockdown) c.successContainer else c.warningContainer)
.border(1.dp, if (lockdown) c.successContainer else c.warningBorder, shape).padding(16.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(RoundedCornerShape(14.dp)).background(if (lockdown) c.success else c.warning))
{
Icon(ShxIcons.Lock, contentDescription = null, tint = Color.White, modifier = Modifier.size(22.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(
stringResource(if (lockdown) R.string.ks_on_title else R.string.ks_off_title),
style = ShxType.cardHeading, color = if (lockdown) c.onSuccessStrong else c.onWarningStrong,
)
Text(
stringResource(if (lockdown) R.string.ks_on_text else R.string.ks_off_text),
style = ShxType.note, color = if (lockdown) c.onSuccess else c.onWarningContainer,
)
}
}
Text(stringResource(R.string.ks_intro), style = ShxType.note.copy(fontSize = 15.sp, lineHeight = 22.sp), color = c.textTonal, modifier = Modifier.padding(horizontal = 4.dp))
listOf(R.string.ks_step1, R.string.ks_step2, R.string.ks_step3).forEachIndexed { i, step ->
val s = RoundedCornerShape(20.dp)
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(s).background(c.card).border(1.dp, c.cardBorder, s).padding(14.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(28.dp).clip(CircleShape).background(c.accent))
{
Text("${i + 1}", style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = Color.White)
}
Text(stringResource(step), style = ShxType.note.copy(fontSize = 14.sp), color = c.text)
}
}
ShxNote(stringResource(R.string.ks_note))
ShxButton(
stringResource(R.string.ks_open),
{ runCatching { context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS)) } },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 16.dp),
)
}
}
/** Update card (F_Update): app icon, version, what is new, progress, Later / Install. */
@Composable
fun UpdateCard(state: UpdateState, installed: String, onCheck: () -> Unit, onInstall: () -> Unit)
{
val c = LocalShxColors.current
val info = (state as? UpdateState.Available)?.info ?: (state as? UpdateState.Downloading)?.info
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
val shape = RoundedCornerShape(24.dp)
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(16.dp),
)
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp))
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(56.dp).clip(RoundedCornerShape(16.dp)).background(c.accent))
{
Icon(painterResource(R.drawable.ic_notification), contentDescription = null, tint = Color.White, modifier = Modifier.size(36.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
{
Text(
if (info != null) "SHX VPN ${info.versionName}" else stringResource(R.string.update_card_uptodate),
style = ShxType.cardHeading.copy(fontSize = 20.sp), color = c.text,
)
Text(
when
{
info != null -> stringResource(R.string.update_card_have, installed, formatBytes(info.size))
state is UpdateState.Checking -> stringResource(R.string.update_card_checking)
state is UpdateState.Failed -> stringResource(R.string.update_card_failed)
else -> stringResource(R.string.update_card_version, installed)
},
style = ShxType.sub, color = c.textSub,
)
}
}
if (info != null)
{
val notes = info.notes["ru"] ?: info.notes["en"] ?: info.notes.values.firstOrNull()
if (!notes.isNullOrBlank())
{
Text(stringResource(R.string.update_card_whats_new).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
notes.split(Regex("(?<=[.;])\\s+")).map { it.trim().trimEnd('.', ';') }.filter { it.isNotEmpty() }.forEach { line ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(c.accent))
Text(line, style = ShxType.note.copy(fontSize = 14.sp), color = c.text)
}
}
}
}
if (state is UpdateState.Downloading)
{
Column(verticalArrangement = Arrangement.spacedBy(6.dp))
{
Row(Modifier.fillMaxWidth())
{
Text(stringResource(R.string.update_card_downloading), style = ShxType.meta, color = c.textSub, modifier = Modifier.weight(1f))
Text("${state.progress}%", style = ShxType.meta, color = c.textSub)
}
Box(Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)).background(c.tile))
{
Box(Modifier.fillMaxWidth(state.progress.coerceIn(0, 100) / 100f).height(6.dp).clip(RoundedCornerShape(3.dp)).background(c.accent))
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
if (info != null)
{
ShxButton(stringResource(R.string.update_card_check), onCheck, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
ShxButton(stringResource(R.string.update_card_install), onInstall, modifier = Modifier.weight(2f), enabled = state !is UpdateState.Downloading)
}
else
{
ShxButton(stringResource(R.string.update_card_check), onCheck, style = ShxButtonStyle.SOFT, modifier = Modifier.fillMaxWidth(), enabled = state !is UpdateState.Checking)
}
}
Text(stringResource(R.string.update_card_footer), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
}
}
@@ -245,24 +245,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
if (page == SettingsPage.APPEARANCE)
{
ShxSection(title = stringResource(R.string.settings_page_appearance))
{
ThemeModeRow()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
{
var dynamicColors by remember { ShxThemeSettings.load(context); mutableStateOf(ShxThemeSettings.useDynamicColors.value) }
ShxSettingToggleRow(
icon = Icons.Rounded.Palette,
title = stringResource(R.string.pref_dynamic_colors_title),
subtitle = stringResource(R.string.pref_dynamic_colors_summary),
checked = dynamicColors,
onCheckedChange = {
dynamicColors = it
ShxThemeSettings.setUseDynamicColors(context, it)
},
)
}
}
AppearancePage()
}
if (page == SettingsPage.STABILITY)
@@ -467,7 +450,35 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
if (page == SettingsPage.UPDATES)
{
ShxSection(title = stringResource(R.string.settings_section_updates))
UpdateCard(
state = updateState,
installed = versionName,
onCheck = {
when (updateState)
{
is UpdateState.Checking, is UpdateState.Downloading -> Unit
else -> updateManager.checkNow()
}
},
onInstall = { showUpdateConfirm = true },
)
}
if (page == SettingsPage.KILLSWITCH)
{
var resumes by remember { mutableStateOf(0) }
val owner = LocalLifecycleOwner.current
DisposableEffect(owner) {
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) resumes++ }
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
KillSwitchPage(remember(resumes) { context.isVpnLockdownEnabled() })
}
if (page == SettingsPage.UPDATES)
{
ShxSection(title = stringResource(R.string.update_settings))
{
var autoUpdate by remember {
mutableStateOf(prefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true))
@@ -733,6 +744,7 @@ enum class SettingsPage(val titleRes: Int)
SECURITY(R.string.settings_section_security),
UPDATES(R.string.settings_section_updates),
SUPPORT(R.string.settings_section_support),
KILLSWITCH(R.string.settings_killswitch),
}
/** One row of the settings list (F_Settings): 62dp, 36dp grey icon well, 15/700 title, 12 sub. */
@@ -787,7 +799,7 @@ private fun SettingsRoot(onOpen: (SettingsPage) -> Unit, updateState: UpdateStat
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 },
) { onOpen(SettingsPage.KILLSWITCH) },
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(
@@ -958,4 +958,29 @@
<string name="form_login_cert">Сертификат</string>
<string name="form_login_both">Оба</string>
<string name="form_more">Дополнительно: порт, MTU, DNS, идентификаторы</string>
<string name="appearance_theme">Тема</string>
<string name="appearance_wallpaper">Цвета обоев (Material You)</string>
<string name="appearance_wallpaper_sub">Вместо фиолетового — цвета вашего телефона. Статусы остаются прежними.</string>
<string name="appearance_language">Язык</string>
<string name="ks_on_title">Сейчас включено</string>
<string name="ks_on_text">Без VPN приложения не выйдут в сеть</string>
<string name="ks_off_title">Сейчас выключено</string>
<string name="ks_off_text">При обрыве VPN приложения на секунды выйдут в сеть напрямую</string>
<string name="ks_intro">Android может блокировать интернет, пока VPN не подключён. Включается в системных настройках — приложение не может сделать это само.</string>
<string name="ks_step1">Нажмите кнопку ниже — откроется раздел VPN в настройках Android.</string>
<string name="ks_step2">Нажмите на шестерёнку рядом с «SHX VPN».</string>
<string name="ks_step3">Включите «Постоянный VPN» и «Блокировать подключения без VPN».</string>
<string name="ks_note">Приложения из списка «напрямую» продолжат работать без VPN — Android выпускает их и при блокировке.</string>
<string name="ks_open">Открыть настройки Android</string>
<string name="update_card_uptodate">У вас последняя версия</string>
<string name="update_card_have">У вас %1$s · %2$s · подпись проверена</string>
<string name="update_card_checking">Проверяем обновления…</string>
<string name="update_card_failed">Не удалось проверить, попробуйте позже</string>
<string name="update_card_version">Версия %1$s</string>
<string name="update_card_whats_new">Что нового</string>
<string name="update_card_downloading">Скачивание</string>
<string name="update_card_check">Проверить</string>
<string name="update_card_install">Установить</string>
<string name="update_card_footer">Текст «Что нового» берётся из заметок релиза</string>
<string name="update_settings">Как обновлять</string>
</resources>
@@ -943,4 +943,29 @@
<string name="form_login_cert">Certificate</string>
<string name="form_login_both">Both</string>
<string name="form_more">More: port, MTU, DNS, identities</string>
<string name="appearance_theme">Theme</string>
<string name="appearance_wallpaper">Wallpaper colors (Material You)</string>
<string name="appearance_wallpaper_sub">Your phone colors instead of purple. Statuses stay the same.</string>
<string name="appearance_language">Language</string>
<string name="ks_on_title">On now</string>
<string name="ks_on_text">Without the VPN apps get no network</string>
<string name="ks_off_title">Off now</string>
<string name="ks_off_text">When the VPN drops, apps go online directly for a few seconds</string>
<string name="ks_intro">Android can block the internet while the VPN is not connected. It is turned on in the system settings: the app cannot do it by itself.</string>
<string name="ks_step1">Tap the button below: the VPN section of the Android settings opens.</string>
<string name="ks_step2">Tap the gear next to «SHX VPN».</string>
<string name="ks_step3">Turn on «Always-on VPN» and «Block connections without VPN».</string>
<string name="ks_note">Apps from the «direct» list keep working without the VPN: Android lets them out even when blocking.</string>
<string name="ks_open">Open Android settings</string>
<string name="update_card_uptodate">You have the latest version</string>
<string name="update_card_have">You have %1$s · %2$s · signature checked</string>
<string name="update_card_checking">Checking for updates…</string>
<string name="update_card_failed">Could not check, try again later</string>
<string name="update_card_version">Version %1$s</string>
<string name="update_card_whats_new">What is new</string>
<string name="update_card_downloading">Downloading</string>
<string name="update_card_check">Check</string>
<string name="update_card_install">Install</string>
<string name="update_card_footer">«What is new» comes from the release notes</string>
<string name="update_settings">How to update</string>
</resources>