From dc8f5a75e12807d0e3d8e4fe978738bf458f03c0 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 12 Oct 2026 10:04:12 +0700 Subject: [PATCH] android: appearance, kill switch and update pages as in the Final 2026 mockup Co-Authored-By: Claude Opus 5.5 --- .../android/ui/compose/SettingsPages.kt | 302 ++++++++++++++++++ .../android/ui/compose/SettingsScreen.kt | 52 +-- .../app/src/main/res/values-ru/strings.xml | 25 ++ .../app/src/main/res/values/strings.xml | 25 ++ 4 files changed, 384 insertions(+), 20 deletions(-) create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsPages.kt diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsPages.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsPages.kt new file mode 100644 index 000000000..249872d7e --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsPages.kt @@ -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)) + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt index 6f8887f09..3ffb0591b 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt @@ -245,24 +245,7 @@ fun SettingsScreen(profiles: List, 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, 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( diff --git a/src/frontends/android/app/src/main/res/values-ru/strings.xml b/src/frontends/android/app/src/main/res/values-ru/strings.xml index 45976a26f..7e1536355 100644 --- a/src/frontends/android/app/src/main/res/values-ru/strings.xml +++ b/src/frontends/android/app/src/main/res/values-ru/strings.xml @@ -958,4 +958,29 @@ Сертификат Оба Дополнительно: порт, MTU, DNS, идентификаторы + Тема + Цвета обоев (Material You) + Вместо фиолетового — цвета вашего телефона. Статусы остаются прежними. + Язык + Сейчас включено + Без VPN приложения не выйдут в сеть + Сейчас выключено + При обрыве VPN приложения на секунды выйдут в сеть напрямую + Android может блокировать интернет, пока VPN не подключён. Включается в системных настройках — приложение не может сделать это само. + Нажмите кнопку ниже — откроется раздел VPN в настройках Android. + Нажмите на шестерёнку рядом с «SHX VPN». + Включите «Постоянный VPN» и «Блокировать подключения без VPN». + Приложения из списка «напрямую» продолжат работать без VPN — Android выпускает их и при блокировке. + Открыть настройки Android + У вас последняя версия + У вас %1$s · %2$s · подпись проверена + Проверяем обновления… + Не удалось проверить, попробуйте позже + Версия %1$s + Что нового + Скачивание + Проверить + Установить + Текст «Что нового» берётся из заметок релиза + Как обновлять diff --git a/src/frontends/android/app/src/main/res/values/strings.xml b/src/frontends/android/app/src/main/res/values/strings.xml index 5c1787b02..acbb1e396 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -943,4 +943,29 @@ Certificate Both More: port, MTU, DNS, identities + Theme + Wallpaper colors (Material You) + Your phone colors instead of purple. Statuses stay the same. + Language + On now + Without the VPN apps get no network + Off now + When the VPN drops, apps go online directly for a few seconds + 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. + Tap the button below: the VPN section of the Android settings opens. + Tap the gear next to «SHX VPN». + Turn on «Always-on VPN» and «Block connections without VPN». + Apps from the «direct» list keep working without the VPN: Android lets them out even when blocking. + Open Android settings + You have the latest version + You have %1$s · %2$s · signature checked + Checking for updates… + Could not check, try again later + Version %1$s + What is new + Downloading + Check + Install + «What is new» comes from the release notes + How to update