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 b3c67329f..6063fe825 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 @@ -126,6 +126,17 @@ import org.strongswan.android.ui.compose.components.ShxSectionDivider import org.strongswan.android.ui.compose.theme.LocalShxStatusColors import org.strongswan.android.ui.compose.theme.ScreenPadding import org.strongswan.android.ui.compose.theme.ShxSpacing +import org.strongswan.android.ui.compose.routing.RoutingHeroCard +import org.strongswan.android.ui.compose.routing.RuToggleRow +import org.strongswan.android.ui.compose.routing.RuModeSelector +import org.strongswan.android.ui.compose.routing.RuDetailsDisclosure +import org.strongswan.android.ui.compose.routing.GeoipChipsRow +import org.strongswan.android.ui.compose.routing.RfActionRow +import org.strongswan.android.ui.compose.routing.RuleSetRow +import org.strongswan.android.ui.compose.routing.AddRuleRow +import org.strongswan.android.ui.compose.routing.ForcePrefixRow +import org.strongswan.android.ui.compose.routing.AddForcePrefixRow +import org.strongswan.android.ui.compose.routing.ForcePrefixDialog @Composable fun RoutingScreen() @@ -548,490 +559,3 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState) ) } } - -/** Hero card: master switch plus a live summary of what the rules currently affect. */ -@Composable -private fun RoutingHeroCard( - enabled: Boolean, - setCount: Int, - appCount: Int, - 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.padding(start = 16.dp, top = 14.dp, bottom = 14.dp, end = 8.dp), - ) - { - Icon( - Icons.Rounded.AltRoute, - contentDescription = null, - tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(16.dp)) - Column(Modifier.weight(1f)) - { - Text( - text = stringResource(if (enabled) R.string.routing_hero_on else R.string.routing_hero_off), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = if (enabled) - { - stringResource(R.string.routing_hero_summary, setCount, appCount) - } - else - { - stringResource(R.string.routing_hero_off_summary) - }, - style = MaterialTheme.typography.bodySmall, - color = if (enabled) - { - MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) - } - else - { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - Switch(checked = enabled, onCheckedChange = onToggle) - Spacer(Modifier.width(8.dp)) - } - } -} - -/** Geoip toggle row with a refresh/update slot in front of the switch. */ -@Composable -private fun RuToggleRow( - title: String, - subtitle: String, - checked: Boolean, - updating: Boolean, - onCheckedChange: (Boolean) -> Unit, - onUpdate: () -> Unit, -) -{ - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable { onCheckedChange(!checked) } - .padding(horizontal = 16.dp, vertical = 14.dp), - ) - { - ShxIconBadge(Icons.Rounded.Block) - Spacer(Modifier.width(16.dp)) - Column(Modifier.weight(1f)) - { - Text(title, style = MaterialTheme.typography.bodyLarge) - Text( - subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (updating) - { - LoadingIndicator(Modifier.size(14.dp)) - Spacer(Modifier.width(12.dp)) - } - else - { - IconButton(onClick = onUpdate) - { - Icon( - Icons.Rounded.Refresh, - contentDescription = stringResource(R.string.rusplit_update), - ) - } - } - Switch(checked = checked, onCheckedChange = onCheckedChange) - } -} - -/** - * Selector for how the RU split is routed: OS routes with the coarsened - * complement (as before) or per-packet classification in native code. - */ -@Composable -private fun RuModeSelector(mode: String, onModeChange: (String) -> Unit) -{ - SingleChoiceSegmentedButtonRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 10.dp), - ) - { - SegmentedButton( - selected = mode == Constants.RUSPLIT_MODE_SYSTEM, - onClick = { onModeChange(Constants.RUSPLIT_MODE_SYSTEM) }, - shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), - ) - { - Text(stringResource(R.string.rusplit_mode_system)) - } - SegmentedButton( - selected = mode == Constants.RUSPLIT_MODE_USERSPACE, - onClick = { onModeChange(Constants.RUSPLIT_MODE_USERSPACE) }, - shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), - ) - { - Text(stringResource(R.string.rusplit_mode_userspace_short)) - } - } -} - -/** Mode description, list source and beta note behind a "Details" disclosure. */ -@Composable -private fun RuDetailsDisclosure(mode: String) -{ - var expanded by rememberSaveable { mutableStateOf(false) } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable { expanded = !expanded } - .padding(horizontal = 16.dp, vertical = 10.dp), - ) - { - Text( - text = stringResource(if (expanded) R.string.routing_details_less else R.string.routing_details_more), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.width(4.dp)) - Icon( - if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - } - AnimatedVisibility( - visible = expanded, - enter = expandVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMediumLow)) + - fadeIn(tween(120)), - exit = shrinkVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMedium)) + - fadeOut(tween(90)), - ) - { - Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp)) - { - Text( - text = stringResource( - if (mode == Constants.RUSPLIT_MODE_USERSPACE) - { - R.string.rusplit_mode_userspace_desc - } - else - { - R.string.rusplit_mode_system_desc - } - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = stringResource(R.string.rusplit_source), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 6.dp), - ) - Text( - text = stringResource(R.string.rusplit_beta_note), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) - } - } -} - -/** Freshness/count chips of the geoip list. */ -@Composable -private fun GeoipChipsRow(state: RuIpListState) -{ - val status = LocalShxStatusColors.current - val fresh = state.lastUpdatedMs > 0L && - System.currentTimeMillis() - state.lastUpdatedMs < 26 * 60 * 60 * 1000L - FlowRow( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) - { - InfoChip(text = stringResource(R.string.routing_geoip_ipv4, state.ipv4Ranges)) - InfoChip(text = stringResource(R.string.routing_geoip_ipv6, state.ipv6Ranges)) - val freshnessText = when - { - state.lastUpdatedMs > 0L -> - stringResource( - R.string.routing_geoip_updated, - DateUtils.getRelativeTimeSpanString(state.lastUpdatedMs).toString(), - ) - else -> stringResource(R.string.routing_geoip_never) - } - InfoChip( - text = freshnessText, - highlight = if (state.lastUpdatedMs > 0L && fresh) status.connected else null, - ) - } -} - -/** Small rounded info pill used across the screen. */ -@Composable -private fun InfoChip(text: String, highlight: Color? = null) -{ - Surface( - shape = RoundedCornerShape(8.dp), - color = if (highlight != null) highlight.copy(alpha = 0.14f) else MaterialTheme.colorScheme.surfaceContainerHighest, - contentColor = highlight ?: MaterialTheme.colorScheme.onSurfaceVariant, - ) - { - Text( - text = text, - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - ) - } -} - -/** Quick action: create/enable rule sets for all RF bundle presets. */ -@Composable -private fun RfActionRow(onClick: () -> Unit) -{ - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 14.dp), - ) - { - ShxIconBadge(Icons.Rounded.Block) - Spacer(Modifier.width(16.dp)) - Column(Modifier.weight(1f)) - { - Text( - stringResource(R.string.routing_rf_quick_title), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - ) - Text( - stringResource(R.string.routing_rf_quick_summary), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -/** Collapsed rule set row: tap opens the editor sheet, the switch toggles in place. */ -@Composable -private fun RuleSetRow( - ruleSet: RuleSet, - onToggle: (Boolean) -> Unit, - onOpen: () -> Unit, -) -{ - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onOpen) - .padding(horizontal = 16.dp, vertical = 14.dp), - ) - { - ShxIconBadge( - Icons.Rounded.Rule, - tint = if (ruleSet.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(16.dp)) - Column(Modifier.weight(1f)) - { - Text( - ruleSet.name.ifBlank { stringResource(R.string.routing_rule_name) }, - style = MaterialTheme.typography.bodyLarge, - color = if (ruleSet.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - stringResource( - R.string.routing_rule_subtitle, - ruleSet.packages.size, - ruleSet.prefixes.size, - ruleSet.keywords.size, - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch(checked = ruleSet.enabled, onCheckedChange = onToggle) - } -} - -/** Trailing "add rule set" row of the rule sets card. */ -@Composable -private fun AddRuleRow(onClick: () -> Unit) -{ - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 14.dp), - ) - { - Icon( - Icons.Rounded.Add, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(22.dp), - ) - Spacer(Modifier.width(16.dp)) - Text( - stringResource(R.string.routing_add_rule), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Medium, - ) - } -} - -/** One force-tunnel prefix: tap edits, switch toggles, trash deletes. */ -@Composable -private fun ForcePrefixRow( - entry: ForceTunnelPrefix, - onToggle: (Boolean) -> Unit, - onEdit: () -> Unit, - onDelete: () -> Unit, -) -{ - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onEdit) - .padding(horizontal = 16.dp, vertical = 8.dp), - ) - { - ShxIconBadge( - Icons.Rounded.Lan, - tint = if (entry.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(16.dp)) - Text( - entry.prefix, - style = MaterialTheme.typography.bodyLarge, - color = if (entry.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - IconButton(onClick = onDelete) - { - Icon( - Icons.Rounded.Delete, - contentDescription = stringResource(R.string.routing_force_delete), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch(checked = entry.enabled, onCheckedChange = onToggle) - } -} - -/** Trailing "add force-tunnel prefix" row. */ -@Composable -private fun AddForcePrefixRow(onClick: () -> Unit) -{ - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 14.dp), - ) - { - Icon( - Icons.Rounded.Add, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(22.dp), - ) - Spacer(Modifier.width(16.dp)) - Text( - stringResource(R.string.routing_force_add), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Medium, - ) - } -} - -/** Add/edit dialog for a force-tunnel prefix; shows an error until valid. */ -@Composable -private fun ForcePrefixDialog(initial: String, onConfirm: (String) -> Boolean, onDismiss: () -> Unit) -{ - var text by remember { mutableStateOf(initial) } - var error by remember { mutableStateOf(false) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.routing_force_dialog_title)) }, - text = { - OutlinedTextField( - value = text, - onValueChange = { - text = it - error = false - }, - singleLine = true, - isError = error, - label = { Text(stringResource(R.string.routing_force_hint)) }, - supportingText = if (error) - { - { Text(stringResource(R.string.routing_force_invalid)) } - } - else - { - null - }, - ) - }, - confirmButton = { - TextButton(onClick = { - if (onConfirm(text.trim())) - { - onDismiss() - } - else - { - error = true - } - }) - { - Text(stringResource(R.string.routing_force_save)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) - { - Text(stringResource(android.R.string.cancel)) - } - }, - ) -} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/AdvancedRouting.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/AdvancedRouting.kt new file mode 100644 index 000000000..5a347cbec --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/AdvancedRouting.kt @@ -0,0 +1,452 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + +package org.strongswan.android.ui.compose.routing + +import org.strongswan.android.ui.compose.components.ShxSnackbarHost +import android.app.Activity +import android.content.Intent +import android.text.format.DateUtils +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +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.background +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +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.Block +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.ExpandLess +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.material.icons.rounded.Lan +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Route +import androidx.compose.material.icons.rounded.Rule +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.material.icons.rounded.ExpandLess +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.strongswan.android.R +import org.strongswan.android.data.VpnProfileDataSource +import org.strongswan.android.logic.CharonVpnService +import org.strongswan.android.logic.routing.ForceTunnelPrefix +import org.strongswan.android.logic.routing.RuleSet +import org.strongswan.android.logic.routing.RoutingManager +import org.strongswan.android.logic.routing.RuIpListManager +import org.strongswan.android.logic.routing.RuIpListState +import org.strongswan.android.logic.routing.RuIpListStatus +import org.strongswan.android.utils.Constants +import org.strongswan.android.ui.RouteCheckActivity +import org.strongswan.android.ui.SelectedApplicationsActivity +import org.strongswan.android.ui.compose.components.ShxEmptyState +import org.strongswan.android.ui.compose.components.ShxIconBadge +import org.strongswan.android.ui.compose.components.ShxSection +import org.strongswan.android.ui.compose.components.ShxSectionDivider +import org.strongswan.android.ui.compose.theme.LocalShxStatusColors +import org.strongswan.android.ui.compose.theme.ScreenPadding +import org.strongswan.android.ui.compose.theme.ShxSpacing + +/** Geoip toggle row with a refresh/update slot in front of the switch. */ +@Composable +internal fun RuToggleRow( + title: String, + subtitle: String, + checked: Boolean, + updating: Boolean, + onCheckedChange: (Boolean) -> Unit, + onUpdate: () -> Unit, +) +{ + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) } + .padding(horizontal = 16.dp, vertical = 14.dp), + ) + { + ShxIconBadge(Icons.Rounded.Block) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) + { + Text(title, style = MaterialTheme.typography.bodyLarge) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (updating) + { + LoadingIndicator(Modifier.size(14.dp)) + Spacer(Modifier.width(12.dp)) + } + else + { + IconButton(onClick = onUpdate) + { + Icon( + Icons.Rounded.Refresh, + contentDescription = stringResource(R.string.rusplit_update), + ) + } + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +/** + * Selector for how the RU split is routed: OS routes with the coarsened + * complement (as before) or per-packet classification in native code. + */ +@Composable +internal fun RuModeSelector(mode: String, onModeChange: (String) -> Unit) +{ + SingleChoiceSegmentedButtonRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + ) + { + SegmentedButton( + selected = mode == Constants.RUSPLIT_MODE_SYSTEM, + onClick = { onModeChange(Constants.RUSPLIT_MODE_SYSTEM) }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), + ) + { + Text(stringResource(R.string.rusplit_mode_system)) + } + SegmentedButton( + selected = mode == Constants.RUSPLIT_MODE_USERSPACE, + onClick = { onModeChange(Constants.RUSPLIT_MODE_USERSPACE) }, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), + ) + { + Text(stringResource(R.string.rusplit_mode_userspace_short)) + } + } +} + +/** Mode description, list source and beta note behind a "Details" disclosure. */ +@Composable +internal fun RuDetailsDisclosure(mode: String) +{ + var expanded by rememberSaveable { mutableStateOf(false) } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(horizontal = 16.dp, vertical = 10.dp), + ) + { + Text( + text = stringResource(if (expanded) R.string.routing_details_less else R.string.routing_details_more), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(4.dp)) + Icon( + if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } + AnimatedVisibility( + visible = expanded, + enter = expandVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMediumLow)) + + fadeIn(tween(120)), + exit = shrinkVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMedium)) + + fadeOut(tween(90)), + ) + { + Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp)) + { + Text( + text = stringResource( + if (mode == Constants.RUSPLIT_MODE_USERSPACE) + { + R.string.rusplit_mode_userspace_desc + } + else + { + R.string.rusplit_mode_system_desc + } + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(R.string.rusplit_source), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 6.dp), + ) + Text( + text = stringResource(R.string.rusplit_beta_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + } +} + +/** Freshness/count chips of the geoip list. */ +@Composable +internal fun GeoipChipsRow(state: RuIpListState) +{ + val status = LocalShxStatusColors.current + val fresh = state.lastUpdatedMs > 0L && + System.currentTimeMillis() - state.lastUpdatedMs < 26 * 60 * 60 * 1000L + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) + { + InfoChip(text = stringResource(R.string.routing_geoip_ipv4, state.ipv4Ranges)) + InfoChip(text = stringResource(R.string.routing_geoip_ipv6, state.ipv6Ranges)) + val freshnessText = when + { + state.lastUpdatedMs > 0L -> + stringResource( + R.string.routing_geoip_updated, + DateUtils.getRelativeTimeSpanString(state.lastUpdatedMs).toString(), + ) + else -> stringResource(R.string.routing_geoip_never) + } + InfoChip( + text = freshnessText, + highlight = if (state.lastUpdatedMs > 0L && fresh) status.connected else null, + ) + } +} + +/** Small rounded info pill used across the screen. */ +@Composable +internal fun InfoChip(text: String, highlight: Color? = null) +{ + Surface( + shape = RoundedCornerShape(8.dp), + color = if (highlight != null) highlight.copy(alpha = 0.14f) else MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = highlight ?: MaterialTheme.colorScheme.onSurfaceVariant, + ) + { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + ) + } +} + +/** One force-tunnel prefix: tap edits, switch toggles, trash deletes. */ +@Composable +internal fun ForcePrefixRow( + entry: ForceTunnelPrefix, + onToggle: (Boolean) -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, +) +{ + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onEdit) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + { + ShxIconBadge( + Icons.Rounded.Lan, + tint = if (entry.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(16.dp)) + Text( + entry.prefix, + style = MaterialTheme.typography.bodyLarge, + color = if (entry.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onDelete) + { + Icon( + Icons.Rounded.Delete, + contentDescription = stringResource(R.string.routing_force_delete), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = entry.enabled, onCheckedChange = onToggle) + } +} + +/** Trailing "add force-tunnel prefix" row. */ +@Composable +internal fun AddForcePrefixRow(onClick: () -> Unit) +{ + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + ) + { + Icon( + Icons.Rounded.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + Spacer(Modifier.width(16.dp)) + Text( + stringResource(R.string.routing_force_add), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + ) + } +} + +/** Add/edit dialog for a force-tunnel prefix; shows an error until valid. */ +@Composable +internal fun ForcePrefixDialog(initial: String, onConfirm: (String) -> Boolean, onDismiss: () -> Unit) +{ + var text by remember { mutableStateOf(initial) } + var error by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.routing_force_dialog_title)) }, + text = { + OutlinedTextField( + value = text, + onValueChange = { + text = it + error = false + }, + singleLine = true, + isError = error, + label = { Text(stringResource(R.string.routing_force_hint)) }, + supportingText = if (error) + { + { Text(stringResource(R.string.routing_force_invalid)) } + } + else + { + null + }, + ) + }, + confirmButton = { + TextButton(onClick = { + if (onConfirm(text.trim())) + { + onDismiss() + } + else + { + error = true + } + }) + { + Text(stringResource(R.string.routing_force_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) + { + Text(stringResource(android.R.string.cancel)) + } + }, + ) +} 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 new file mode 100644 index 000000000..a3cd329cb --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingHero.kt @@ -0,0 +1,220 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + +package org.strongswan.android.ui.compose.routing + +import org.strongswan.android.ui.compose.components.ShxSnackbarHost +import android.app.Activity +import android.content.Intent +import android.text.format.DateUtils +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +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.background +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +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.Block +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.ExpandLess +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.material.icons.rounded.Lan +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Route +import androidx.compose.material.icons.rounded.Rule +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.material.icons.rounded.ExpandLess +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.strongswan.android.R +import org.strongswan.android.data.VpnProfileDataSource +import org.strongswan.android.logic.CharonVpnService +import org.strongswan.android.logic.routing.ForceTunnelPrefix +import org.strongswan.android.logic.routing.RuleSet +import org.strongswan.android.logic.routing.RoutingManager +import org.strongswan.android.logic.routing.RuIpListManager +import org.strongswan.android.logic.routing.RuIpListState +import org.strongswan.android.logic.routing.RuIpListStatus +import org.strongswan.android.utils.Constants +import org.strongswan.android.ui.RouteCheckActivity +import org.strongswan.android.ui.SelectedApplicationsActivity +import org.strongswan.android.ui.compose.components.ShxEmptyState +import org.strongswan.android.ui.compose.components.ShxIconBadge +import org.strongswan.android.ui.compose.components.ShxSection +import org.strongswan.android.ui.compose.components.ShxSectionDivider +import org.strongswan.android.ui.compose.theme.LocalShxStatusColors +import org.strongswan.android.ui.compose.theme.ScreenPadding +import org.strongswan.android.ui.compose.theme.ShxSpacing + +/** Hero card: master switch plus a live summary of what the rules currently affect. */ +@Composable +internal fun RoutingHeroCard( + enabled: Boolean, + setCount: Int, + appCount: Int, + 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.padding(start = 16.dp, top = 14.dp, bottom = 14.dp, end = 8.dp), + ) + { + Icon( + Icons.Rounded.AltRoute, + contentDescription = null, + tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) + { + Text( + text = stringResource(if (enabled) R.string.routing_hero_on else R.string.routing_hero_off), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = if (enabled) + { + stringResource(R.string.routing_hero_summary, setCount, appCount) + } + else + { + stringResource(R.string.routing_hero_off_summary) + }, + style = MaterialTheme.typography.bodySmall, + color = if (enabled) + { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) + } + else + { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + Switch(checked = enabled, onCheckedChange = onToggle) + Spacer(Modifier.width(8.dp)) + } + } +} + +/** Quick action: create/enable rule sets for all RF bundle presets. */ +@Composable +internal fun RfActionRow(onClick: () -> Unit) +{ + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + ) + { + ShxIconBadge(Icons.Rounded.Block) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) + { + Text( + stringResource(R.string.routing_rf_quick_title), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + ) + Text( + stringResource(R.string.routing_rf_quick_summary), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RuleSetList.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RuleSetList.kt new file mode 100644 index 000000000..358fcc30f --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RuleSetList.kt @@ -0,0 +1,199 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) + +package org.strongswan.android.ui.compose.routing + +import org.strongswan.android.ui.compose.components.ShxSnackbarHost +import android.app.Activity +import android.content.Intent +import android.text.format.DateUtils +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +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.background +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +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.Block +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.ExpandLess +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.material.icons.rounded.Lan +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Route +import androidx.compose.material.icons.rounded.Rule +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.material.icons.rounded.ExpandLess +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.strongswan.android.R +import org.strongswan.android.data.VpnProfileDataSource +import org.strongswan.android.logic.CharonVpnService +import org.strongswan.android.logic.routing.ForceTunnelPrefix +import org.strongswan.android.logic.routing.RuleSet +import org.strongswan.android.logic.routing.RoutingManager +import org.strongswan.android.logic.routing.RuIpListManager +import org.strongswan.android.logic.routing.RuIpListState +import org.strongswan.android.logic.routing.RuIpListStatus +import org.strongswan.android.utils.Constants +import org.strongswan.android.ui.RouteCheckActivity +import org.strongswan.android.ui.SelectedApplicationsActivity +import org.strongswan.android.ui.compose.components.ShxEmptyState +import org.strongswan.android.ui.compose.components.ShxIconBadge +import org.strongswan.android.ui.compose.components.ShxSection +import org.strongswan.android.ui.compose.components.ShxSectionDivider +import org.strongswan.android.ui.compose.theme.LocalShxStatusColors +import org.strongswan.android.ui.compose.theme.ScreenPadding +import org.strongswan.android.ui.compose.theme.ShxSpacing + +/** Collapsed rule set row: tap opens the editor sheet, the switch toggles in place. */ +@Composable +internal fun RuleSetRow( + ruleSet: RuleSet, + onToggle: (Boolean) -> Unit, + onOpen: () -> Unit, +) +{ + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onOpen) + .padding(horizontal = 16.dp, vertical = 14.dp), + ) + { + ShxIconBadge( + Icons.Rounded.Rule, + tint = if (ruleSet.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) + { + Text( + ruleSet.name.ifBlank { stringResource(R.string.routing_rule_name) }, + style = MaterialTheme.typography.bodyLarge, + color = if (ruleSet.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + stringResource( + R.string.routing_rule_subtitle, + ruleSet.packages.size, + ruleSet.prefixes.size, + ruleSet.keywords.size, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = ruleSet.enabled, onCheckedChange = onToggle) + } +} + +/** Trailing "add rule set" row of the rule sets card. */ +@Composable +internal fun AddRuleRow(onClick: () -> Unit) +{ + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + ) + { + Icon( + Icons.Rounded.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + Spacer(Modifier.width(16.dp)) + Text( + stringResource(R.string.routing_add_rule), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + ) + } +}