diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/RemediationInstructionsActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/RemediationInstructionsActivity.kt index dd0c1aacc..fb9751d9c 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/RemediationInstructionsActivity.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/RemediationInstructionsActivity.kt @@ -40,9 +40,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.CheckCircle -import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -61,6 +58,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.strongswan.android.R +import org.strongswan.android.ui.compose.design.ShxIcons import org.strongswan.android.ui.compose.components.PredictiveBackScale import org.strongswan.android.logic.imc.RemediationInstruction import org.strongswan.android.ui.compose.components.ShxCard @@ -143,7 +141,7 @@ private fun RemediationUi( navigationIcon = { IconButton(onClick = { if (selected != null) onSelect(null) else onFinish() }) { Icon( - Icons.AutoMirrored.Rounded.ArrowBack, + ShxIcons.Back, contentDescription = stringResource(R.string.back), ) } @@ -167,7 +165,7 @@ private fun RemediationUi( if (instructions.isEmpty()) { ShxEmptyState( - icon = Icons.Rounded.CheckCircle, + icon = ShxIcons.Check, message = stringResource(R.string.remediation_instructions_title), modifier = Modifier.padding(ShxSpacing.lg), ) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ServerActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ServerActivity.kt index 86b1185c4..c9fbfdec5 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ServerActivity.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ServerActivity.kt @@ -43,7 +43,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -80,6 +79,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.strongswan.android.R +import org.strongswan.android.ui.compose.design.ShxDialogButton import org.strongswan.android.data.VpnProfile import org.strongswan.android.data.VpnProfileDataSource import org.strongswan.android.data.VpnProfileSource @@ -724,8 +724,8 @@ private fun TextDialog( text = { ShxFormField(label = label, value = text, onValueChange = { text = it }, keyboardOptions = KeyboardOptions(keyboardType = keyboard)) }, - confirmButton = { TextButton(onClick = { onDone(text) }, enabled = validate(text.trim())) { Text(stringResource(R.string.server_save)) } }, - dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } }, + confirmButton = { ShxDialogButton(primary = true, onClick = { onDone(text) }, enabled = validate(text.trim())) { Text(stringResource(R.string.server_save)) } }, + dismissButton = { ShxDialogButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } }, ) } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/TrustedCertificateImportActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/TrustedCertificateImportActivity.kt index dfe176ba5..372e38fac 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/TrustedCertificateImportActivity.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/TrustedCertificateImportActivity.kt @@ -27,7 +27,6 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import org.strongswan.android.ui.compose.design.ShxAlertDialog import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -38,6 +37,7 @@ import java.security.cert.CertificateException import java.security.cert.CertificateFactory import java.security.cert.X509Certificate import org.strongswan.android.R +import org.strongswan.android.ui.compose.design.ShxDialogButton import org.strongswan.android.logic.TrustedCertificateManager import org.strongswan.android.ui.compose.theme.ShxTheme @@ -180,7 +180,7 @@ class TrustedCertificateImportActivity : AppCompatActivity() title = { Text(stringResource(R.string.import_certificate)) }, text = { Text(certificate.getSubjectDN().toString()) }, confirmButton = { - TextButton(onClick = { + ShxDialogButton(primary = true, onClick = { if (storeCertificate(certificate)) { Toast.makeText(this, R.string.cert_imported_successfully, Toast.LENGTH_LONG).show() @@ -194,7 +194,7 @@ class TrustedCertificateImportActivity : AppCompatActivity() }) { Text(stringResource(R.string.import_certificate)) } }, dismissButton = { - TextButton(onClick = { finish() }) { + ShxDialogButton(onClick = { finish() }) { Text(stringResource(android.R.string.cancel)) } }, diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ConnectivityCheckScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ConnectivityCheckScreen.kt index 2e92bf61c..4ec31717e 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ConnectivityCheckScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ConnectivityCheckScreen.kt @@ -22,15 +22,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.BatterySaver -import androidx.compose.material.icons.rounded.HealthAndSafety -import androidx.compose.material.icons.rounded.NetworkCheck -import androidx.compose.material.icons.rounded.Remove -import androidx.compose.material.icons.rounded.Public -import androidx.compose.material.icons.rounded.Speed -import androidx.compose.material.icons.rounded.Timer import org.strongswan.android.ui.compose.design.ShxAlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -40,7 +31,6 @@ import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -52,6 +42,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import org.strongswan.android.R +import org.strongswan.android.ui.compose.design.ShxDialogButton +import org.strongswan.android.ui.compose.design.ShxIcons import org.strongswan.android.ui.compose.components.ShxSection import org.strongswan.android.ui.compose.components.ShxSectionDivider import org.strongswan.android.ui.compose.components.ShxSettingRow @@ -92,7 +84,7 @@ fun ConnectivityCheckScreen() ShxSection(title = stringResource(R.string.settings_section_general)) { ShxSettingToggleRow( - icon = Icons.Rounded.NetworkCheck, + icon = ShxIcons.Pulse, title = stringResource(R.string.health_check_title), subtitle = stringResource(R.string.health_check_summary), checked = config.enabled, @@ -100,7 +92,7 @@ fun ConnectivityCheckScreen() ) ShxSectionDivider() ShxSettingRow( - icon = Icons.Rounded.Timer, + icon = ShxIcons.Clock, title = stringResource(R.string.health_check_interval), subtitle = intervalLabel(config.intervalSec, config.aggressiveMode), enabled = config.enabled && !config.aggressiveMode, @@ -108,7 +100,7 @@ fun ConnectivityCheckScreen() ) ShxSectionDivider() ShxSettingToggleRow( - icon = Icons.Rounded.Speed, + icon = ShxIcons.Bolt, title = stringResource(R.string.health_check_aggressive_title), subtitle = stringResource(R.string.health_check_aggressive_summary), checked = config.aggressiveMode, @@ -116,7 +108,7 @@ fun ConnectivityCheckScreen() ) ShxSectionDivider() ShxSettingToggleRow( - icon = Icons.Rounded.BatterySaver, + icon = ShxIcons.Battery, title = stringResource(R.string.health_check_adaptive_title), subtitle = stringResource(R.string.health_check_adaptive_summary), checked = config.adaptiveMode, @@ -127,7 +119,7 @@ fun ConnectivityCheckScreen() ShxSection(title = stringResource(R.string.health_check_targets_title)) { ShxSettingToggleRow( - icon = Icons.Rounded.HealthAndSafety, + icon = ShxIcons.Shield, title = stringResource(R.string.health_check_server_title), subtitle = stringResource(R.string.health_check_server_summary), checked = config.serverCheckMode != ServerCheckMode.OFF, @@ -140,7 +132,7 @@ fun ConnectivityCheckScreen() listOf(ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE).forEach { target -> ShxSectionDivider() ShxSettingToggleRow( - icon = Icons.Rounded.Public, + icon = ShxIcons.Globe, title = targetLabel(target), subtitle = null, checked = config.targets.contains(target), @@ -175,7 +167,7 @@ fun ConnectivityCheckScreen() } ShxSectionDivider() ShxSettingRow( - icon = Icons.Rounded.NetworkCheck, + icon = ShxIcons.Pulse, title = stringResource(R.string.health_check_action), subtitle = actionLabel(config.serverAction), enabled = config.enabled, @@ -203,7 +195,7 @@ fun ConnectivityCheckScreen() ShxSection(title = stringResource(R.string.health_check_tunnel_dead_title)) { ShxSettingRow( - icon = Icons.Rounded.NetworkCheck, + icon = ShxIcons.Pulse, title = stringResource(R.string.health_check_action), subtitle = actionLabel(config.tunnelDeadAction), enabled = config.enabled, @@ -231,7 +223,7 @@ fun ConnectivityCheckScreen() ShxSection(title = stringResource(R.string.health_check_partial_title)) { ShxSettingRow( - icon = Icons.Rounded.NetworkCheck, + icon = ShxIcons.Pulse, title = stringResource(R.string.health_check_action), subtitle = actionLabel(config.partialDownAction), enabled = config.enabled, @@ -276,7 +268,7 @@ fun ConnectivityCheckScreen() } }, confirmButton = { - TextButton(onClick = { showIntervalPicker = false }) { + ShxDialogButton(onClick = { showIntervalPicker = false }) { Text(stringResource(android.R.string.cancel)) } }, @@ -341,7 +333,7 @@ private fun AttemptsRow( modifier = Modifier.weight(1f), ) IconButton(onClick = onDecrement, enabled = enabled && attempts > 1) { - Icon(Icons.Rounded.Remove, contentDescription = stringResource(R.string.health_check_attempt_minus)) + Icon(ShxIcons.Minus, contentDescription = stringResource(R.string.health_check_attempt_minus)) } Text( text = attempts.toString(), @@ -349,7 +341,7 @@ private fun AttemptsRow( modifier = Modifier.padding(horizontal = ShxSpacing.sm), ) IconButton(onClick = onIncrement, enabled = enabled && attempts < 5) { - Icon(Icons.Rounded.Add, contentDescription = stringResource(R.string.health_check_attempt_plus)) + Icon(ShxIcons.Plus, contentDescription = stringResource(R.string.health_check_attempt_plus)) } } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt index cca2376f1..019ec7119 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt @@ -52,25 +52,6 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.AltRoute -import androidx.compose.material.icons.rounded.ArrowDownward -import androidx.compose.material.icons.rounded.ArrowUpward -import androidx.compose.material.icons.rounded.ChevronRight -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.Dns -import androidx.compose.material.icons.rounded.ExpandLess -import androidx.compose.material.icons.rounded.GppMaybe -import androidx.compose.material.icons.rounded.Language -import androidx.compose.material.icons.rounded.PlayArrow -import androidx.compose.material.icons.rounded.Public -import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material.icons.rounded.Schedule -import androidx.compose.material.icons.rounded.Share -import androidx.compose.material.icons.rounded.SignalCellularAlt -import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material.icons.rounded.Wifi import org.strongswan.android.ui.compose.design.ShxAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -126,6 +107,15 @@ import org.strongswan.android.ui.compose.home.certExpiry import org.strongswan.android.ui.compose.home.snoozeCertExpiry import org.strongswan.android.ui.compose.home.connectProgress import org.strongswan.android.R +import androidx.compose.ui.draw.clip +import org.strongswan.android.ui.compose.theme.ShxType +import org.strongswan.android.ui.compose.theme.LocalShxColors +import org.strongswan.android.ui.compose.design.ShxButtonStyle +import org.strongswan.android.ui.compose.design.ShxButton +import org.strongswan.android.ui.compose.design.ShxPanel +import org.strongswan.android.ui.compose.design.ShxSheetTitle +import org.strongswan.android.ui.compose.design.ShxSheet +import org.strongswan.android.ui.compose.design.ShxIcons import androidx.compose.foundation.layout.widthIn import org.strongswan.android.logic.routing.RuIpListManager import org.strongswan.android.ui.DiagnosticsActivity @@ -143,7 +133,6 @@ import org.strongswan.android.ui.compose.components.ShxCard import org.strongswan.android.ui.compose.components.ShxEmptyState import org.strongswan.android.ui.compose.theme.LocalShxStatusColors import org.strongswan.android.ui.compose.theme.ScreenPadding -import androidx.compose.material.icons.rounded.Check import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.heading @@ -229,8 +218,6 @@ fun HomeScreen( } } } - var pendingDelete by remember { mutableStateOf(null) } - var sheetProfile by remember { mutableStateOf(null) } var pickerOpen by remember { mutableStateOf(false) } var pauseSheetOpen by remember { mutableStateOf(false) } val networkKinds by viewModel.networkKinds.collectAsStateWithLifecycle() @@ -385,7 +372,7 @@ fun HomeScreen( }, onLongPick = { profile -> pickerOpen = false - sheetProfile = profile + context.startActivity(org.strongswan.android.ui.ServerActivity.intent(context, profile.getUUID().toString())) }, onManage = { pickerOpen = false @@ -417,69 +404,10 @@ fun HomeScreen( ) } - if (state.detailsOpen) - { - ConnectionDetailsSheet( - profile = state.activeProfile ?: state.selected, - traffic = traffic, - durationSec = durationSec, - probes = probes, - exit = exit, - onDismiss = { viewModel.openDetails(false) }, - onDisconnect = { - viewModel.openDetails(false) - viewModel.disconnect() - }, - ) - } - - sheetProfile?.let { profile -> - ProfileActionsSheet( - profile = profile, - active = connected && profile.getUUID() == state.activeProfile?.getUUID(), - onDismiss = { sheetProfile = null }, - onConnect = { - sheetProfile = null - viewModel.select(profile) - viewModel.connect(context, profile) - }, - onEdit = { - sheetProfile = null - onEditProfile(profile) - }, - onShare = { - sheetProfile = null - onShareProfile(profile) - }, - onDelete = { - sheetProfile = null - pendingDelete = profile - }, - ) - } - - pendingDelete?.let { profile -> - ShxAlertDialog( - onDismissRequest = { pendingDelete = null }, - title = { Text(stringResource(R.string.delete_profile)) }, - text = { Text(profile.name ?: "") }, - confirmButton = { - TextButton(onClick = { - viewModel.delete(profile) - pendingDelete = null - }) { Text(stringResource(R.string.delete_profile)) } - }, - dismissButton = { - TextButton(onClick = { pendingDelete = null }) { - Text(stringResource(android.R.string.cancel)) - } - }, - ) - } } -/** Bottom sheet to switch the profile without leaving the Connect tab. */ -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +/** «Server» sheet of Home: the servers as on F_Servers, manage / add below. */ +@OptIn(ExperimentalFoundationApi::class) @Composable private fun ProfilePickerSheet( profiles: List, @@ -492,240 +420,58 @@ private fun ProfilePickerSheet( onAdd: () -> Unit, ) { + val c = LocalShxColors.current /* the current profile first, the rest in list order */ val ordered = remember(profiles, selected) { profiles.sortedByDescending { it.getUUID() == selected?.getUUID() } } - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - ) + ShxSheet(onDismiss = onDismiss) { - Column(Modifier.fillMaxWidth()) + ShxSheetTitle(stringResource(R.string.home_profile_picker_title)) + ShxPanel() { - Text( - text = stringResource(R.string.home_profile_picker_title), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.sm).semantics { heading() }, - ) ordered.forEachIndexed { index, profile -> - ShxProfileRow( - profile = profile, - active = profile.getUUID() == activeUuid, - onClick = { onPick(profile) }, - onLongClick = { onLongPick(profile) }, - onLongClickLabel = stringResource(R.string.profile_actions), - trailing = if (profile.getUUID() == selected?.getUUID() && profile.getUUID() != activeUuid) - { - { Icon(Icons.Rounded.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary) } - } - else null, + val active = profile.getUUID() == activeUuid + val chosen = profile.getUUID() == selected?.getUUID() + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().height(68.dp) + .combinedClickable( + onClick = { onPick(profile) }, + onLongClick = { onLongPick(profile) }, + onLongClickLabel = stringResource(R.string.profile_actions), + ) + .padding(horizontal = 14.dp), ) + { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(CircleShape).background(if (active) c.accent else c.tile)) + { + Text( + org.strongswan.android.ui.compose.home.serverCode(profile, null), + 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) + Text(profile.gateway ?: "", style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, maxLines = 1) + } + if (chosen) + { + Icon(ShxIcons.Check, contentDescription = null, tint = c.accent, modifier = Modifier.size(20.dp)) + } + } if (index < ordered.lastIndex) { - ShxDivider() + Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile)) } } - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.sm), - horizontalArrangement = Arrangement.SpaceBetween, - ) - { - TextButton(onClick = onManage) { Text(stringResource(R.string.home_manage_profiles)) } - TextButton(onClick = onAdd) { - Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.size(ShxSpacing.xs)) - Text(stringResource(R.string.add_profile_cta)) - } - } - Spacer(Modifier.height(ShxSpacing.lg)) } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ConnectionDetailsSheet( - profile: VpnProfile?, - traffic: TrafficUi, - durationSec: Long, - probes: Map, - exit: ExitUi?, - onDismiss: () -> Unit, - onDisconnect: () -> Unit, -) -{ - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - ) - { - Column(Modifier.padding(horizontal = 24.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) - { - ProfileAvatar(icon = Icons.Rounded.Language, connected = true) - Column { - Text( - profile?.name ?: "", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - ) - Text( - profile?.gateway ?: "", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - Spacer(Modifier.height(16.dp)) - DetailRow(icon = Icons.Rounded.Language, label = stringResource(R.string.profile_gateway_label), value = profile?.gateway ?: "—") - DetailRow(icon = Icons.Rounded.Schedule, label = stringResource(R.string.duration_label), value = formatDuration(durationSec)) - DetailRow( - icon = Icons.Rounded.ArrowDownward, - label = stringResource(R.string.traffic_down), - value = "${formatBytes(traffic.bytesIn)} · ${formatRate(traffic.rateIn)}", - ) - DetailRow( - icon = Icons.Rounded.ArrowUpward, - label = stringResource(R.string.traffic_up), - value = "${formatBytes(traffic.bytesOut)} · ${formatRate(traffic.rateOut)}", - ) - /* RU-split session breakdown; null in the system-routes mode where - * the engine never sees the direct traffic */ - traffic.split?.let { split -> - DetailRow( - icon = Icons.Rounded.AltRoute, - label = stringResource(R.string.traffic_split_tunnel), - value = "${formatBytes(split.tunnelBytes)} · ${formatRate(split.tunnelRate)}", - ) - DetailRow( - icon = Icons.Rounded.AltRoute, - label = stringResource(R.string.traffic_split_direct), - value = "${formatBytes(split.directBytes)} · ${formatRate(split.directRate)}", - ) - } - exit?.let { e -> - e.ip?.let { ip -> - DetailRow( - icon = Icons.Rounded.Public, - label = stringResource(R.string.exit_ip_label), - value = e.country?.let { "$ip · $it" } ?: ip, - ) - } - e.network?.let { network -> - DetailRow( - icon = if (e.ssid != null) Icons.Rounded.Wifi else Icons.Rounded.SignalCellularAlt, - label = stringResource(R.string.network_label), - value = e.ssid?.let { "$network · $it" } ?: network, - ) - } - } - if (probes.isNotEmpty()) - { - probes.sortedForDisplay().forEach { result -> - DetailRow( - icon = Icons.Rounded.Dns, - label = stringResource(result.target.labelRes()), - value = result.rttText(), - ) - } - } - Spacer(Modifier.height(24.dp)) - FilledTonalButton( - onClick = onDisconnect, - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ), - shape = MaterialTheme.shapes.medium, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.disconnect)) - } - Spacer(Modifier.height(32.dp)) + ShxButton(stringResource(R.string.home_manage_profiles), onManage, style = ShxButtonStyle.SOFT, height = 48.dp, modifier = Modifier.weight(1f)) + ShxButton(stringResource(R.string.add_profile_cta), onAdd, style = ShxButtonStyle.OUTLINE, height = 48.dp, modifier = Modifier.weight(1f), icon = ShxIcons.Plus) } } } - -@Composable -private fun DetailRow(icon: ImageVector, label: String, value: String) -{ - ListItem( - leadingContent = { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - ) { - Icon( - icon, - contentDescription = null, - modifier = Modifier - .padding(8.dp) - .size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - headlineContent = { Text(label) }, - supportingContent = { Text(value) }, - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ProfileActionsSheet( - profile: VpnProfile, - active: Boolean, - onDismiss: () -> Unit, - onConnect: () -> Unit, - onEdit: () -> Unit, - onShare: () -> Unit, - onDelete: () -> Unit, -) -{ - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - ) - { - Column(Modifier.fillMaxWidth()) - { - ShxProfileRow(profile = profile, active = active, onClick = {}, onLongClick = {}) - ShxDivider(indent = false) - SheetAction(icon = Icons.Rounded.PlayArrow, label = stringResource(R.string.connect), onClick = onConnect) - ShxDivider(indent = false) - SheetAction(icon = Icons.Rounded.Edit, label = stringResource(R.string.edit_profile), onClick = onEdit) - ShxDivider(indent = false) - SheetAction(icon = Icons.Rounded.Share, label = stringResource(R.string.share_profile), onClick = onShare) - ShxDivider(indent = false) - SheetAction( - icon = Icons.Rounded.Delete, - label = stringResource(R.string.delete_profile), - tint = MaterialTheme.colorScheme.error, - onClick = onDelete, - ) - Spacer(Modifier.height(24.dp)) - } - } -} - -@Composable -private fun SheetAction(icon: ImageVector, label: String, onClick: () -> Unit, tint: Color = Color.Unspecified) -{ - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .combinedClickable(onClick = onClick) - .padding(horizontal = 24.dp, vertical = 16.dp), - ) - { - Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp)) - Spacer(Modifier.size(16.dp)) - Text(label, style = MaterialTheme.typography.bodyLarge) - } -} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileFormScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileFormScreen.kt index 86ab3c693..c85670efd 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileFormScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileFormScreen.kt @@ -39,12 +39,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material.icons.rounded.AltRoute import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenuItem @@ -410,7 +404,7 @@ fun ProfileFormScreen( trailing = { Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(CircleShape).clickable { showPassword = !showPassword }) { - Icon(if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, null, tint = c.textSub, modifier = Modifier.size(20.dp)) + Icon(if (showPassword) ShxIcons.EyeOff else ShxIcons.Eye, null, tint = c.textSub, modifier = Modifier.size(20.dp)) } }, ) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileRow.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileRow.kt deleted file mode 100644 index 0fde27ae0..000000000 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileRow.kt +++ /dev/null @@ -1,135 +0,0 @@ -/* - * 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 org.strongswan.android.ui.compose.components.ShxConnectedPill -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -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.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Language -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import org.strongswan.android.R -import org.strongswan.android.data.VpnProfile -import org.strongswan.android.ui.compose.theme.LocalShxStatusColors -import org.strongswan.android.ui.compose.theme.ShxSpacing - -@Composable -fun ProfileAvatar(icon: ImageVector, connected: Boolean, modifier: Modifier = Modifier) -{ - val status = LocalShxStatusColors.current - val container = if (connected) status.connectedContainer else MaterialTheme.colorScheme.primaryContainer - val tint = if (connected) status.connected else MaterialTheme.colorScheme.primary - Box( - modifier = modifier - .size(40.dp) - .background(container, CircleShape), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = tint, - modifier = Modifier.size(22.dp), - ) - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ShxProfileRow( - profile: VpnProfile, - active: Boolean, - leadingIcon: ImageVector = Icons.Rounded.Language, - onClick: () -> Unit, - onLongClick: () -> Unit = {}, - onLongClickLabel: String? = null, - trailing: (@Composable () -> Unit)? = null, -) -{ - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .combinedClickable( - onClick = onClick, - onLongClick = onLongClick, - onLongClickLabel = onLongClickLabel, - ) - .padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md), - ) { - ProfileAvatar(icon = leadingIcon, connected = active) - Column( - modifier = Modifier - .weight(1f) - .padding(horizontal = ShxSpacing.md), - ) { - Text( - text = profile.name ?: "", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = profile.gateway ?: "", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - if (trailing != null) - { - trailing() - } - else if (active) - { - ActiveBadge() - } - } -} - -@Composable -fun ActiveBadge(modifier: Modifier = Modifier) -{ - ShxConnectedPill(text = stringResource(R.string.badge_connected), modifier = modifier) -} - -@Composable -fun ShxDivider(modifier: Modifier = Modifier, indent: Boolean = true) -{ - Surface( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f), - modifier = modifier - .fillMaxWidth() - .padding(start = if (indent) 68.dp else 0.dp, end = if (indent) ShxSpacing.lg else 0.dp) - .height(1.dp), - ) {} -} 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 18fb0d7f5..698b994bd 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 @@ -17,18 +17,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.Close -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material.icons.rounded.Language -import androidx.compose.material.icons.rounded.MoreVert -import androidx.compose.material.icons.rounded.PlayArrow -import androidx.compose.material.icons.rounded.Search -import androidx.compose.material.icons.rounded.SearchOff -import androidx.compose.material.icons.rounded.Share -import androidx.compose.material.icons.rounded.Stop import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -172,7 +160,7 @@ fun ProfilesScreen( { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - ShxEmptyState(icon = Icons.Rounded.Language, message = stringResource(R.string.profile_detail_placeholder)) + ShxEmptyState(icon = ShxIcons.Globe, message = stringResource(R.string.profile_detail_placeholder)) } } } @@ -425,7 +413,7 @@ private fun ServerRow(profile: VpnProfile, active: Boolean, ping: ServerPing?, o 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)) + Icon(ShxIcons.More, contentDescription = stringResource(R.string.action_more_options), tint = c.textSub, modifier = Modifier.size(20.dp)) } } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt index 4185245ce..d6b9d0398 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt @@ -107,11 +107,8 @@ import org.strongswan.android.ui.compose.components.PredictiveBackScale import org.strongswan.android.ui.compose.components.ShxCard import org.strongswan.android.ui.compose.components.ShxSnackbarHost import org.strongswan.android.ui.compose.routing.DeadHostsPage -import org.strongswan.android.ui.compose.routing.DirectAppsCard import org.strongswan.android.ui.compose.routing.DirectAppsScreen import org.strongswan.android.ui.compose.routing.RecentAppsPage -import org.strongswan.android.ui.compose.routing.RoutingHeroCard -import org.strongswan.android.ui.compose.routing.RuSitesCard import org.strongswan.android.ui.compose.routing.RulesPage import org.strongswan.android.ui.compose.routing.routingSummary import org.strongswan.android.ui.compose.theme.LocalShxStatusColors 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 d74ff61a3..c030a8e6b 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 @@ -24,32 +24,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Autorenew -import androidx.compose.material.icons.rounded.BatteryChargingFull -import androidx.compose.material.icons.rounded.Bedtime -import androidx.compose.material.icons.rounded.Bolt -import androidx.compose.material.icons.rounded.BugReport -import androidx.compose.material.icons.rounded.Cached -import androidx.compose.material.icons.rounded.CloudDownload -import androidx.compose.material.icons.rounded.CloudSync -import androidx.compose.material.icons.rounded.Description -import androidx.compose.material.icons.rounded.FileDownload -import androidx.compose.material.icons.rounded.GppGood -import androidx.compose.material.icons.rounded.GppMaybe -import androidx.compose.material.icons.rounded.History -import androidx.compose.material.icons.rounded.NetworkCheck -import androidx.compose.material.icons.rounded.Palette -import androidx.compose.material.icons.rounded.RestartAlt -import androidx.compose.material.icons.rounded.Security -import androidx.compose.material.icons.rounded.SystemUpdateAlt -import androidx.compose.material.icons.rounded.VerifiedUser -import androidx.compose.material.icons.rounded.Wifi import org.strongswan.android.ui.compose.design.ShxAlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -77,7 +55,6 @@ 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 @@ -101,6 +78,7 @@ import androidx.compose.runtime.produceState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.strongswan.android.R +import org.strongswan.android.ui.compose.design.ShxDialogButton import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.background @@ -284,14 +262,14 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) ShxSectionDivider() } ShxSettingRow( - icon = Icons.Rounded.History, + icon = ShxIcons.Clock, title = stringResource(R.string.pref_default_vpn_profile), subtitle = defaultLabel, onClick = { showDefaultPicker = true }, ) ShxSectionDivider() ShxSettingToggleRow( - icon = Icons.Rounded.BatteryChargingFull, + icon = ShxIcons.Battery, title = stringResource(R.string.pref_power_whitelist_title), subtitle = stringResource(R.string.pref_power_whitelist_summary), checked = ignorePower, @@ -306,7 +284,7 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) * disabled, which breaks watchdog-based recovery of the tunnel */ ShxSectionDivider() ShxSettingRow( - icon = Icons.Rounded.Bedtime, + icon = ShxIcons.Moon, title = stringResource(R.string.samsung_sleep_title), subtitle = stringResource(R.string.samsung_sleep_summary), onClick = { openSamsungBatterySettings(context) }, @@ -323,7 +301,7 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) mutableStateOf(WhiteListManager.getInstance(context).currentConfig.enabled) } ShxSettingToggleRow( - icon = Icons.Rounded.GppMaybe, + icon = ShxIcons.Alert, title = stringResource(R.string.whitelist_analysis_title), subtitle = stringResource(R.string.whitelist_analysis_summary), checked = whiteListEnabled, @@ -337,7 +315,7 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) mutableStateOf(VpnShortcutManager.getInstance(context).isEnabled()) } ShxSettingToggleRow( - icon = Icons.Rounded.Bolt, + icon = ShxIcons.Bolt, title = stringResource(R.string.pref_routines_shortcuts_title), subtitle = stringResource(R.string.pref_routines_shortcuts_summary), checked = shortcutsEnabled, @@ -351,7 +329,7 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) mutableStateOf(prefs.getBoolean(Constants.PREF_WATCHDOG_ENABLED, true)) } ShxSettingToggleRow( - icon = Icons.Rounded.RestartAlt, + icon = ShxIcons.Refresh, title = stringResource(R.string.watchdog_title), subtitle = stringResource(R.string.watchdog_summary), checked = watchdogEnabled, @@ -377,7 +355,7 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) mutableStateOf(prefs.getBoolean(Constants.PREF_SAFE_IPV6, false)) } ShxSettingToggleRow( - icon = Icons.Rounded.GppGood, + icon = ShxIcons.Shield, title = stringResource(R.string.safe_ipv6_title), subtitle = stringResource(R.string.safe_ipv6_summary), checked = safeIpv6, @@ -394,7 +372,7 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) ShxSection(title = stringResource(R.string.settings_section_certs)) { ShxSettingRow( - icon = Icons.Rounded.VerifiedUser, + icon = ShxIcons.Cert, title = stringResource(R.string.trusted_certs_title), onClick = { context.startActivity(Intent(context, TrustedCertificatesActivity::class.java)) @@ -402,7 +380,7 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) ) ShxSectionDivider() ShxSettingRow( - icon = Icons.Rounded.Cached, + icon = ShxIcons.Refresh, title = stringResource(R.string.crl_cache), onClick = { showCrl = true }, ) @@ -428,7 +406,7 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) mutableStateOf(prefs.getBoolean(Constants.PREF_EXTENDED_DIAG, false)) } ShxSettingToggleRow( - icon = Icons.Rounded.BugReport, + icon = ShxIcons.Bug, title = stringResource(R.string.extended_diag_title), subtitle = stringResource(R.string.extended_diag_summary), checked = extendedDiag, @@ -476,7 +454,7 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) mutableStateOf(prefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true)) } ShxSettingToggleRow( - icon = Icons.Rounded.Autorenew, + icon = ShxIcons.Refresh, title = stringResource(R.string.update_auto_check_title), subtitle = stringResource(R.string.update_auto_check_summary), checked = autoUpdate, @@ -490,7 +468,7 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) mutableStateOf(prefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)) } ShxSettingToggleRow( - icon = Icons.Rounded.CloudSync, + icon = ShxIcons.Refresh, title = stringResource(R.string.update_silent_title), subtitle = stringResource(R.string.update_silent_summary), checked = silentUpdates, @@ -501,7 +479,7 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) ) ShxSectionDivider() ShxSettingRow( - icon = Icons.Rounded.SystemUpdateAlt, + icon = ShxIcons.Download, title = stringResource(R.string.update_check_title), subtitle = updateStatusText(updateState), onClick = { @@ -518,7 +496,7 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) { ShxSectionDivider() ShxSettingRow( - icon = Icons.Rounded.CloudDownload, + icon = ShxIcons.Download, title = stringResource(R.string.update_download_install), subtitle = when (val state = updateState) { @@ -583,7 +561,7 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) } }, confirmButton = { - TextButton(onClick = { showDefaultPicker = false }) { + ShxDialogButton(onClick = { showDefaultPicker = false }) { Text(stringResource(android.R.string.cancel)) } }, @@ -603,7 +581,7 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) ) }, confirmButton = { - TextButton(onClick = { + ShxDialogButton(primary = true, danger = true, onClick = { if (files.isEmpty()) { scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.clear_crl_cache_msg_none)) } @@ -616,7 +594,7 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) }) { Text(stringResource(R.string.clear)) } }, dismissButton = { - TextButton(onClick = { showCrl = false }) { + ShxDialogButton(onClick = { showCrl = false }) { Text(stringResource(android.R.string.cancel)) } }, @@ -632,13 +610,13 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) title = { Text(stringResource(R.string.update_confirm_title, info.versionName)) }, text = { if (notes.isNotEmpty()) { Text(notes) } }, confirmButton = { - TextButton(onClick = { + ShxDialogButton(primary = true, onClick = { showUpdateConfirm = false startUpdate() }) { Text(stringResource(R.string.update_action)) } }, dismissButton = { - TextButton(onClick = { showUpdateConfirm = false }) { + ShxDialogButton(onClick = { showUpdateConfirm = false }) { Text(stringResource(android.R.string.cancel)) } }, @@ -930,13 +908,13 @@ private fun SettingsRoot(onOpen: (SettingsPage) -> Unit, updateState: UpdateStat title = { Text(stringResource(R.string.kill_switch_dialog_title)) }, text = { Text(stringResource(R.string.kill_switch_dialog_text)) }, confirmButton = { - TextButton(onClick = { + ShxDialogButton(primary = true, onClick = { showKillSwitchDialog = false context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS)) }) { Text(stringResource(R.string.kill_switch_dialog_enable)) } }, dismissButton = { - TextButton(onClick = { showKillSwitchDialog = false }) { + ShxDialogButton(onClick = { showKillSwitchDialog = false }) { Text(stringResource(android.R.string.cancel)) } }, @@ -999,20 +977,20 @@ private fun KillSwitchRow() title = { Text(stringResource(R.string.kill_switch_dialog_title)) }, text = { Text(stringResource(R.string.kill_switch_dialog_text)) }, confirmButton = { - TextButton(onClick = { + ShxDialogButton(primary = true, onClick = { showKillSwitchDialog = false context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS)) }) { Text(stringResource(R.string.kill_switch_dialog_enable)) } }, dismissButton = { - TextButton(onClick = { showKillSwitchDialog = false }) { + ShxDialogButton(onClick = { showKillSwitchDialog = false }) { Text(stringResource(android.R.string.cancel)) } }, ) } ShxSettingRow( - icon = Icons.Rounded.Security, + icon = ShxIcons.Shield, title = stringResource(R.string.kill_switch_title), subtitle = stringResource( if (lockdownActive) R.string.kill_switch_summary_on diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/TrustedNetworksScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/TrustedNetworksScreen.kt index 75fed137d..c42a43ec8 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/TrustedNetworksScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/TrustedNetworksScreen.kt @@ -33,11 +33,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.Language -import androidx.compose.material.icons.rounded.Wifi import org.strongswan.android.ui.compose.design.ShxAlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -67,6 +62,7 @@ import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.foundation.layout.Arrangement import org.strongswan.android.R +import org.strongswan.android.ui.compose.design.ShxIcons import org.strongswan.android.ui.compose.components.ShxSection import org.strongswan.android.ui.compose.components.ShxSectionDivider import org.strongswan.android.ui.compose.components.ShxSettingRow @@ -169,7 +165,7 @@ fun TrustedNetworksScreen( ShxSection(title = stringResource(R.string.settings_section_general)) { ShxSettingToggleRow( - icon = Icons.Rounded.Wifi, + icon = ShxIcons.Wifi, title = stringResource(R.string.auto_connect_title), subtitle = stringResource(R.string.auto_connect_summary), checked = enabled, @@ -181,7 +177,7 @@ fun TrustedNetworksScreen( ) ShxSectionDivider() ShxSettingRow( - icon = Icons.Rounded.Language, + icon = ShxIcons.Globe, title = stringResource(R.string.auto_connect_profile), subtitle = selectedProfile?.name ?: stringResource(R.string.auto_connect_profile_none), @@ -209,7 +205,7 @@ fun TrustedNetworksScreen( { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md)) { - Icon(Icons.Rounded.Wifi, contentDescription = null) + Icon(ShxIcons.Wifi, contentDescription = null) Text( text = stringResource(R.string.auto_connect_location_title), style = MaterialTheme.typography.titleSmall, @@ -269,7 +265,7 @@ fun TrustedNetworksScreen( ) { Icon( - Icons.Rounded.Wifi, + ShxIcons.Wifi, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp), @@ -283,7 +279,7 @@ fun TrustedNetworksScreen( ) IconButton(onClick = { pendingDeleteSsid = ssid }) { Icon( - Icons.Rounded.Delete, + ShxIcons.Trash, contentDescription = stringResource(R.string.delete_profile), tint = MaterialTheme.colorScheme.error, ) @@ -410,7 +406,7 @@ private fun TrustedAddNetworkRow( { TextButton(onClick = { onAddCurrent(currentSsid) }) { - Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Icon(ShxIcons.Plus, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.size(ShxSpacing.sm)) Text(stringResource(R.string.auto_connect_add_current, currentSsid)) } @@ -419,7 +415,7 @@ private fun TrustedAddNetworkRow( { TextButton(onClick = onAddManual) { - Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Icon(ShxIcons.Plus, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.size(ShxSpacing.sm)) Text(stringResource(R.string.auto_connect_add_network)) } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/VpnDialogs.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/VpnDialogs.kt index b7c9e03bb..5e40f76af 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/VpnDialogs.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/VpnDialogs.kt @@ -19,9 +19,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Visibility -import androidx.compose.material.icons.rounded.VisibilityOff import org.strongswan.android.ui.compose.design.ShxAlertDialog import org.strongswan.android.ui.compose.design.ShxFormField import androidx.compose.material3.Icon @@ -29,7 +26,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -62,6 +58,7 @@ import org.strongswan.android.ui.compose.design.ShxIcons import org.strongswan.android.ui.compose.theme.LocalShxColors import org.strongswan.android.ui.compose.theme.ShxType import org.strongswan.android.R +import org.strongswan.android.ui.compose.design.ShxDialogButton import org.strongswan.android.ui.compose.theme.ShxSpacing import org.strongswan.android.ui.compose.theme.ShxTheme @@ -182,7 +179,7 @@ private fun ConfirmationUi(dialog: VpnDialog.Confirmation) Row(horizontalArrangement = Arrangement.spacedBy(ShxSpacing.xs)) { if (dialog.reconnect) { - TextButton(onClick = { + ShxDialogButton(onClick = { VpnDialogBus.dismiss() VpnDialogBus.listener?.onVpnDialogDisconnect() }) { @@ -192,7 +189,7 @@ private fun ConfirmationUi(dialog: VpnDialog.Confirmation) ) } } - TextButton(onClick = { + ShxDialogButton(primary = true, onClick = { VpnDialogBus.dismiss() val listener = VpnDialogBus.listener if (dialog.disconnect) @@ -207,7 +204,7 @@ private fun ConfirmationUi(dialog: VpnDialog.Confirmation) } }, dismissButton = { - TextButton(onClick = ::cancelDialog) { + ShxDialogButton(onClick = ::cancelDialog) { Text(stringResource(android.R.string.cancel)) } }, @@ -245,7 +242,7 @@ private fun LoginUi(dialog: VpnDialog.Login) trailing = { IconButton(onClick = { showPassword = !showPassword }) { Icon( - if (showPassword) Icons.Rounded.Visibility else Icons.Rounded.VisibilityOff, + if (showPassword) ShxIcons.Eye else ShxIcons.EyeOff, contentDescription = stringResource( if (showPassword) R.string.login_hide_password else R.string.login_show_password ), @@ -256,13 +253,13 @@ private fun LoginUi(dialog: VpnDialog.Login) } }, confirmButton = { - TextButton(onClick = { + ShxDialogButton(primary = true, onClick = { VpnDialogBus.dismiss() VpnDialogBus.listener?.onVpnDialogLogin(dialog.profileInfo, password) }) { Text(stringResource(R.string.login_confirm)) } }, dismissButton = { - TextButton(onClick = ::cancelDialog) { + ShxDialogButton(onClick = ::cancelDialog) { Text(stringResource(android.R.string.cancel)) } }, @@ -277,13 +274,13 @@ private fun CertificateRequiredUi(dialog: VpnDialog.CertificateRequired) title = { Text(stringResource(R.string.certificate_required_title)) }, text = { Text(stringResource(R.string.certificate_required_text)) }, confirmButton = { - TextButton(onClick = { + ShxDialogButton(primary = true, onClick = { VpnDialogBus.dismiss() VpnDialogBus.listener?.onVpnDialogEditProfile(dialog.uuid) }) { Text(stringResource(R.string.edit_profile)) } }, dismissButton = { - TextButton(onClick = ::cancelDialog) { + ShxDialogButton(onClick = ::cancelDialog) { Text(stringResource(android.R.string.cancel)) } }, @@ -298,7 +295,7 @@ private fun PowerWhitelistUi() title = { Text(stringResource(R.string.power_whitelist_title)) }, text = { Text(stringResource(R.string.power_whitelist_text)) }, confirmButton = { - TextButton(onClick = { + ShxDialogButton(primary = true, onClick = { VpnDialogBus.dismiss() VpnDialogBus.listener?.onVpnDialogPowerWhitelist() }) { Text(stringResource(android.R.string.ok)) } @@ -314,7 +311,7 @@ private fun NotSupportedUi(dialog: VpnDialog.NotSupported) title = { Text(stringResource(R.string.vpn_not_supported_title)) }, text = { Text(stringResource(dialog.message)) }, confirmButton = { - TextButton(onClick = ::cancelDialog) { + ShxDialogButton(primary = true, onClick = ::cancelDialog) { Text(stringResource(android.R.string.ok)) } }, diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxStatusPill.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxStatusPill.kt deleted file mode 100644 index 941116024..000000000 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxStatusPill.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * 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.components - -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp -import org.strongswan.android.ui.compose.theme.LocalShxStatusColors -import org.strongswan.android.ui.compose.theme.ShxSpacing - -/** Small rounded label with explicit colors, e.g. "Connected". */ -@Composable -fun ShxStatusPill( - text: String, - containerColor: Color, - contentColor: Color, - modifier: Modifier = Modifier, - shape: Shape = CircleShape, - style: TextStyle = MaterialTheme.typography.labelSmall, -) -{ - Surface( - color = containerColor, - contentColor = contentColor, - shape = shape, - modifier = modifier, - ) { - Text( - text = text, - style = style, - modifier = Modifier.padding(horizontal = 10.dp, vertical = ShxSpacing.xs), - ) - } -} - -/** Pill in the "connected" status palette (independent of dynamic color). */ -@Composable -fun ShxConnectedPill(text: String, modifier: Modifier = Modifier) -{ - val status = LocalShxStatusColors.current - ShxStatusPill( - text = text, - containerColor = status.connectedContainer, - contentColor = status.connected, - modifier = modifier, - ) -} 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 e64678460..49c26f0d3 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 @@ -73,4 +73,14 @@ object ShxIcons val Pin = line("pin", "M12 21s-7-6.1-7-11a7 7 0 0 1 14 0c0 4.9-7 11-7 11zM12 12.5a2.5 2.5 0 1 0 0-5a2.5 2.5 0 0 0 0 5", 2f) val Stats = line("stats", "M5 20V10M12 20V4M19 20v-7", 2.4f) val Clock = line("clock", "M12 7v5l3 2M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18", 2.2f) + val Eye = line("eye", "M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12zM12 9a3 3 0 1 0 0 6a3 3 0 1 0 0-6", 2f) + val EyeOff = line("eyeOff", "M3 3l18 18M10.6 5.1A10 10 0 0 1 12 5c6.4 0 10 7 10 7a17 17 0 0 1-3.2 4.2M6.6 6.6C3.9 8.4 2 12 2 12s3.6 7 10 7a9.6 9.6 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2", 2f) + val More = line("more", "M12 5h.01M12 12h.01M12 19h.01", 3f) + val Battery = line("battery", "M3 8h15v8H3zM21 11v2M6 11v2", 2f) + val Bug = line("bug", "M9 9h6v8a3 3 0 0 1-6 0zM9 13H5M19 13h-4M8 5l2 2M16 5l-2 2M10 7h4", 2f) + val Edit = line("edit", "M4 20h4l10-10-4-4L4 16zM13 7l4 4", 2f) + val ArrowUp = line("arrowUp", "M12 19V5M6 11l6-6 6 6", 2.2f) + val ArrowDown = line("arrowDown", "M12 5v14M6 13l6 6 6-6", 2.2f) + val Minus = line("minus", "M5 12h14", 2.4f) + val Share = line("share", "M18 8a3 3 0 1 0 0-6a3 3 0 0 0 0 6zM6 15a3 3 0 1 0 0-6a3 3 0 0 0 0 6zM18 22a3 3 0 1 0 0-6a3 3 0 0 0 0 6zM8.6 13.5l6.8 4M15.4 6.5l-6.8 4", 2f) } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxKit.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxKit.kt index 96cf668b1..653a8aa78 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxKit.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/design/ShxKit.kt @@ -39,9 +39,6 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.ModalBottomSheet @@ -107,7 +104,7 @@ fun ShxPageHeader(title: String, onBack: () -> Unit, modifier: Modifier = Modifi modifier = Modifier.size(44.dp).clip(CircleShape).clickable(role = Role.Button, onClick = onBack), ) { - Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.back), tint = c.text, modifier = Modifier.size(22.dp)) + Icon(ShxIcons.Back, contentDescription = stringResource(R.string.back), tint = c.text, modifier = Modifier.size(22.dp)) } Text( title, @@ -259,7 +256,7 @@ fun ShxRow( trailing?.invoke(this) if (chevron) { - Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null, tint = c.textSub, modifier = Modifier.size(20.dp)) + Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(20.dp)) } } if (divider) @@ -875,3 +872,49 @@ private fun DialogScrim() } } } + +/** + * A dialog button of the mockup: the action as a filled 44dp pill (red when + * [danger]), «Cancel» and the like as accent text. Same shape of call as + * the Material TextButton it replaces. + */ +@Composable +fun ShxDialogButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + primary: Boolean = false, + danger: Boolean = false, + content: @Composable RowScope.() -> Unit, +) +{ + val c = LocalShxColors.current + val bg = when + { + !primary -> Color.Transparent + danger -> c.error + else -> c.accent + } + val fg = when + { + !primary -> if (danger) c.error else c.accentText + danger -> c.background + else -> Color.White + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = modifier.height(44.dp).clip(RoundedCornerShape(22.dp)).background(if (enabled) bg else bg.copy(alpha = 0.4f)) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .padding(horizontal = if (primary) 20.dp else 16.dp), + ) + { + androidx.compose.runtime.CompositionLocalProvider(androidx.compose.material3.LocalContentColor provides if (enabled) fg else fg.copy(alpha = 0.5f)) + { + androidx.compose.material3.ProvideTextStyle(if (primary) ShxType.button else ShxType.button.copy(fontWeight = FontWeight.Bold)) + { + content() + } + } + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/DirectAppsCard.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/DirectAppsCard.kt deleted file mode 100644 index 33820cbe7..000000000 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/DirectAppsCard.kt +++ /dev/null @@ -1,185 +0,0 @@ -/* - * 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. See . - * - * This program is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY - * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * for more details. - */ - -package org.strongswan.android.ui.compose.routing - -import androidx.compose.foundation.BorderStroke -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.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.Apps -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import org.strongswan.android.R -import org.strongswan.android.ui.compose.components.ShxCard -import org.strongswan.android.ui.compose.components.ShxIconBadge -import org.strongswan.android.ui.compose.theme.ShxSpacing - -private const val STACK_ICONS = 3 - -/** - * "Apps direct" entry card: counter, a stack of real app icons and a chevron - * into the full list. While the list is empty it offers the one-tap preset - * for Russian apps instead. - * - * @param previewPackages a few direct packages for the icon stack (the rest is a "+N" bubble) - */ -@Composable -internal fun DirectAppsCard( - count: Int, - previewPackages: List, - onOpen: () -> Unit, - onAddAllRf: () -> Unit, - modifier: Modifier = Modifier, -) -{ - ShxCard(modifier = modifier.fillMaxWidth()) - { - Column { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onOpen) - .heightIn(min = 72.dp) - .padding(ShxSpacing.lg), - ) - { - ShxIconBadge(Icons.Rounded.Apps) - Column( - Modifier - .weight(1f) - .padding(horizontal = ShxSpacing.lg), - ) - { - Text( - text = stringResource(R.string.routing_apps_direct_title), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - ) - Text( - text = if (count > 0) - { - pluralStringResource(R.plurals.routing_apps_direct_summary, count, count) - } - else - { - stringResource(R.string.routing_apps_direct_empty) - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (count > 0) - { - IconStack(previewPackages.take(STACK_ICONS), extra = count - previewPackages.take(STACK_ICONS).size) - Spacer(Modifier.width(ShxSpacing.sm)) - } - Icon( - Icons.AutoMirrored.Rounded.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (count == 0) - { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm), - modifier = Modifier.padding(start = 72.dp, end = ShxSpacing.lg, bottom = ShxSpacing.lg), - ) - { - Button(onClick = onAddAllRf) - { - Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(6.dp)) - Text(stringResource(R.string.routing_apps_add_all_rf)) - } - OutlinedButton(onClick = onOpen) - { - Text(stringResource(R.string.routing_apps_pick_manually)) - } - } - } - } - } -} - -/** Overlapping app icons with a "+N" bubble for the rest. */ -@Composable -private fun IconStack(packages: List, extra: Int) -{ - val ring = MaterialTheme.colorScheme.surfaceContainerLow - Row(verticalAlignment = Alignment.CenterVertically) - { - packages.forEachIndexed { index, pkg -> - Box( - Modifier - .offset(x = (-8 * index).dp) - .size(28.dp) - .border(BorderStroke(2.dp, ring), CircleShape) - .padding(2.dp) - .clip(CircleShape), - ) - { - AppIcon(pkg, 24.dp) - } - } - if (extra > 0) - { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.surfaceContainerHighest, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .offset(x = (-8 * packages.size).dp) - .heightIn(min = 28.dp), - ) - { - Text( - text = "+$extra", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 5.dp), - ) - } - } - } -} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHero.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHero.kt deleted file mode 100644 index ef9e0dd6d..000000000 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHero.kt +++ /dev/null @@ -1,128 +0,0 @@ -/* - * 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. See . - * - * This program is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY - * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * for more details. - */ - -package org.strongswan.android.ui.compose.routing - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.selection.toggleable -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.AltRoute -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.pluralStringResource -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 org.strongswan.android.R -import org.strongswan.android.ui.compose.theme.ShxSpacing - -/** Plain-words summary of what currently bypasses the VPN. */ -@Composable -internal fun routingSummary(enabled: Boolean, ruEnabled: Boolean, appCount: Int, ruleCount: Int = 0): String -{ - if (!enabled) - { - return stringResource(R.string.routing_hero_off_summary) - } - val parts = ArrayList() - if (ruEnabled) - { - parts.add(stringResource(R.string.routing_hero_part_sites)) - } - if (appCount > 0) - { - parts.add(pluralStringResource(R.plurals.routing_apps_n, appCount, appCount)) - } - if (ruleCount > 0) - { - parts.add(pluralStringResource(R.plurals.routing_rules_n, ruleCount, ruleCount)) - } - return if (parts.isEmpty()) - { - stringResource(R.string.routing_hero_nothing) - } - else - { - stringResource(R.string.routing_hero_summary, parts.joinToString(" · ")) - } -} - -/** Hero card: the master switch plus a one-line summary of what bypasses the VPN. */ -@Composable -internal fun RoutingHeroCard( - enabled: Boolean, - summary: String, - onToggle: (Boolean) -> Unit, - modifier: Modifier = Modifier, -) -{ - Surface( - shape = MaterialTheme.shapes.large, - color = if (enabled) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerLow, - contentColor = if (enabled) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface, - border = if (enabled) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), - modifier = modifier.fillMaxWidth(), - ) - { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .toggleable(value = enabled, role = Role.Switch, onValueChange = onToggle) - .padding(start = ShxSpacing.lg, top = 14.dp, bottom = 14.dp, end = ShxSpacing.lg), - ) - { - Icon( - Icons.Rounded.AltRoute, - contentDescription = null, - tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(ShxSpacing.lg)) - Column(Modifier.weight(1f)) - { - Text( - text = stringResource(R.string.routing_hero_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = summary, - style = MaterialTheme.typography.bodyMedium, - color = if (enabled) - { - MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f) - } - else - { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - Spacer(Modifier.width(ShxSpacing.md)) - Switch(checked = enabled, onCheckedChange = null) - } - } -} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHub.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHub.kt index 9c4e5d74a..b49fabafc 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHub.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHub.kt @@ -307,3 +307,33 @@ internal fun RecentAppsCard(apps: List, failing: Int, onClick: () } } +/** Plain-words summary of what currently bypasses the VPN. */ +@Composable +internal fun routingSummary(enabled: Boolean, ruEnabled: Boolean, appCount: Int, ruleCount: Int = 0): String +{ + if (!enabled) + { + return stringResource(R.string.routing_hero_off_summary) + } + val parts = ArrayList() + if (ruEnabled) + { + parts.add(stringResource(R.string.routing_hero_part_sites)) + } + if (appCount > 0) + { + parts.add(pluralStringResource(R.plurals.routing_apps_n, appCount, appCount)) + } + if (ruleCount > 0) + { + parts.add(pluralStringResource(R.plurals.routing_rules_n, ruleCount, ruleCount)) + } + return if (parts.isEmpty()) + { + stringResource(R.string.routing_hero_nothing) + } + else + { + stringResource(R.string.routing_hero_summary, parts.joinToString(" · ")) + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RuSitesCard.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RuSitesCard.kt deleted file mode 100644 index 4fd3a3f78..000000000 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RuSitesCard.kt +++ /dev/null @@ -1,288 +0,0 @@ -/* - * 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. See . - * - * This program is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY - * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * for more details. - */ - -package org.strongswan.android.ui.compose.routing - -import android.text.format.DateUtils -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -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.selection.toggleable -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Public -import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material.icons.rounded.Warning -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LoadingIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -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 org.strongswan.android.R -import org.strongswan.android.logic.routing.RuIpListState -import org.strongswan.android.logic.routing.RuIpListStatus -import org.strongswan.android.ui.compose.components.ShxCard -import org.strongswan.android.ui.compose.components.ShxIconBadge -import org.strongswan.android.ui.compose.theme.ShxSpacing -import org.strongswan.android.utils.Constants -import java.text.NumberFormat - -/** - * "Russian sites direct": the switch, the two ways to do it as selectable - * cards (right under the switch), a warning for the flexible mode and the - * freshness of the address database with a refresh button. - */ -@Composable -internal fun RuSitesCard( - state: RuIpListState, - onToggle: (Boolean) -> Unit, - onModeChange: (String) -> Unit, - onRefresh: () -> Unit, - modifier: Modifier = Modifier, -) -{ - ShxCard(modifier = modifier.fillMaxWidth()) - { - Column( - verticalArrangement = Arrangement.spacedBy(14.dp), - modifier = Modifier.padding(ShxSpacing.lg), - ) - { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.toggleable(value = state.enabled, role = Role.Switch, onValueChange = onToggle), - ) - { - ShxIconBadge(Icons.Rounded.Public) - Column( - Modifier - .weight(1f) - .padding(horizontal = ShxSpacing.lg), - ) - { - Text( - text = stringResource(R.string.rusplit_toggle_title), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - ) - Text( - text = stringResource(R.string.rusplit_toggle_summary), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch(checked = state.enabled, onCheckedChange = null) - } - AnimatedVisibility( - visible = state.enabled, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) - { - Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.md)) - { - Text( - text = stringResource(R.string.routing_mode_label), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row( - horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm), - modifier = Modifier.height(IntrinsicSize.Max), - ) - { - val stable = state.mode != Constants.RUSPLIT_MODE_USERSPACE - ModeCard( - title = stringResource(R.string.routing_mode_stable), - description = stringResource(R.string.routing_mode_stable_desc), - selected = stable, - beta = false, - onClick = { onModeChange(Constants.RUSPLIT_MODE_SYSTEM) }, - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - ) - ModeCard( - title = stringResource(R.string.routing_mode_flexible), - description = stringResource(R.string.routing_mode_flexible_desc), - selected = !stable, - beta = true, - onClick = { onModeChange(Constants.RUSPLIT_MODE_USERSPACE) }, - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - ) - } - AnimatedVisibility(visible = state.mode == Constants.RUSPLIT_MODE_USERSPACE) - { - Surface( - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - ) - { - Row(Modifier.padding(ShxSpacing.md), horizontalArrangement = Arrangement.spacedBy(10.dp)) - { - Icon(Icons.Rounded.Warning, contentDescription = null, modifier = Modifier.size(18.dp)) - Text( - text = stringResource(R.string.routing_mode_flexible_warning), - style = MaterialTheme.typography.bodySmall, - ) - } - } - } - DatabaseStatusRow(state = state, onRefresh = onRefresh) - } - } - } - } -} - -@Composable -private fun ModeCard( - title: String, - description: String, - selected: Boolean, - beta: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) -{ - Surface( - shape = MaterialTheme.shapes.medium, - color = if (selected) MaterialTheme.colorScheme.primaryContainer else androidx.compose.ui.graphics.Color.Transparent, - contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface, - border = if (selected) - { - BorderStroke(2.dp, MaterialTheme.colorScheme.primary) - } - else - { - BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) - }, - modifier = modifier.selectable(selected = selected, role = Role.RadioButton, onClick = onClick), - ) - { - Column(Modifier.padding(ShxSpacing.md), verticalArrangement = Arrangement.spacedBy(6.dp)) - { - Row(verticalAlignment = Alignment.CenterVertically) - { - RadioButton(selected = selected, onClick = null, modifier = Modifier.size(20.dp)) - Spacer(Modifier.width(ShxSpacing.sm)) - /* FlowRow: on narrow cards the BETA badge drops below the title - * as a whole instead of wrapping letter by letter */ - androidx.compose.foundation.layout.FlowRow( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - itemVerticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f), - ) - { - Text(text = title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) - if (beta) - { - Surface( - shape = MaterialTheme.shapes.extraSmall, - color = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer, - ) - { - Text( - text = stringResource(R.string.routing_mode_beta), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - softWrap = false, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - ) - } - } - } - } - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -/** "Address database: N networks - updated 3 h ago" with the refresh button. */ -@OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun DatabaseStatusRow(state: RuIpListState, onRefresh: () -> Unit) -{ - val text = if (state.lastUpdatedMs > 0L) - { - stringResource( - R.string.routing_db_status, - NumberFormat.getIntegerInstance().format(state.ipv4Ranges + state.ipv6Ranges), - DateUtils.getRelativeTimeSpanString(state.lastUpdatedMs).toString(), - ) - } - else - { - stringResource(R.string.routing_db_never) - } - Row(verticalAlignment = Alignment.CenterVertically) - { - Text( - text = text, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - if (state.status == RuIpListStatus.UPDATING) - { - LoadingIndicator(Modifier.size(24.dp)) - Spacer(Modifier.width(ShxSpacing.md)) - } - else - { - IconButton(onClick = onRefresh) - { - Icon( - Icons.Rounded.Refresh, - contentDescription = stringResource(R.string.routing_db_refresh), - tint = MaterialTheme.colorScheme.primary, - ) - } - } - } -}