diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfilesScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfilesScreen.kt index 2105f8835..c5a2b3d52 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfilesScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfilesScreen.kt @@ -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(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(), 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, + ) + } +} 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 20dc9bce3..6f8887f09 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 @@ -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, 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, 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)) + } + }, + ) } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt index b18a245ce..29725fda1 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt @@ -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) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxIcons.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxIcons.kt index ffdffef04..9e855e017 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxIcons.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxIcons.kt @@ -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) } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/DirectAppsScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/DirectAppsScreen.kt index 04ca15c3c..7f2413b7a 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/DirectAppsScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/DirectAppsScreen.kt @@ -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) } } 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 5b196b214..7dcb01f52 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 @@ -861,4 +861,39 @@ напрямую VPN Прочитать все + Мои серверы · %1$d + Один сервер — один профиль .sswan. + Автоматически + Самый быстрый, что подключается + проверяем… + не отвечает + подключён · %1$d мс + доступен · %1$d мс + Стабильная работа + Батарея, сервер по умолчанию, фон + Доверенные сети + Включено, сетей пока нет + Выключено + Каждые %1$d с · переподключать + Белые списки, ярлыки, сторож + Защита + Без VPN — без сети + Включено · постоянный VPN + Выключено — нажмите, чтобы включить + IPv6, сертификаты, списки отзыва + Приложение + Уведомления + Проблемы — важные, остальное — тихо + Версия %1$s + Доступна версия %1$s + Журнал + Для поддержки + Поиск по настройкам + + %d доверенный сертификат + %d доверенных сертификата + %d доверенных сертификатов + %d доверенных сертификата + + Включённые приложения ходят в сеть напрямую, мимо VPN. Банки и сервисы РФ часто не работают через VPN. 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 bf6659a00..5ae83b9eb 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -848,4 +848,37 @@ direct VPN Read all + My servers · %1$d + One server is one .sswan profile. + Automatically + The fastest one that connects + checking… + no answer + connected · %1$d ms + available · %1$d ms + Stable work + Battery, default server, background + Trusted networks + On, no networks yet + Off + Every %1$d s · reconnect + Whitelists, shortcuts, watchdog + Protection + No VPN, no network + On · always-on VPN + Off — tap to turn on + IPv6, certificates, CRL + App + Notifications + Problems loud, the rest quiet + Version %1$s + Version %1$s is available + Log + For support + Search settings + + %d trusted certificate + %d trusted certificates + + Enabled apps reach the network directly, bypassing the VPN. Banks and RU services often do not work through a VPN.