From d29b6255b769e16cf873a0862a7a62a459bdb88b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 27 Dec 2025 12:55:27 +0800 Subject: [PATCH] Refactor: groups --- .../sfa/compose/ComposeActivity.kt | 167 +++++++++-- .../sfa/compose/GroupsComposeActivity.kt | 1 - .../sfa/compose/component/ServiceStatusBar.kt | 185 +++++++++++++ .../sfa/compose/navigation/SFANavigation.kt | 10 +- .../screen/dashboard/DashboardCardRenderer.kt | 11 - .../screen/dashboard/DashboardScreen.kt | 57 ---- .../dashboard/DashboardSettingsBottomSheet.kt | 4 - .../screen/dashboard/DashboardViewModel.kt | 25 +- .../compose/screen/dashboard/GroupsCard.kt | 259 ++++++------------ .../dashboard/groups/GroupsViewModel.kt | 46 +++- .../sfa/compose/screen/log/LogScreen.kt | 55 ++-- app/src/main/res/values-zh-rCN/strings.xml | 6 +- app/src/main/res/values/strings.xml | 6 +- 13 files changed, 485 insertions(+), 347 deletions(-) create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/component/ServiceStatusBar.kt diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt index e18e7d0..f760ac5 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt @@ -10,8 +10,16 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.ui.text.font.FontWeight import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ExpandLess @@ -20,11 +28,14 @@ import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.UnfoldLess +import androidx.compose.material.icons.filled.UnfoldMore import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import dev.jeziellago.compose.markdowntext.MarkdownText @@ -37,15 +48,18 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold +import androidx.compose.material3.SheetState import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -57,6 +71,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavDestination.Companion.hierarchy @@ -71,12 +87,15 @@ import io.nekohasekai.sfa.bg.ServiceConnection import io.nekohasekai.sfa.bg.ServiceNotification import io.nekohasekai.sfa.compose.base.GlobalEventBus import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.component.ServiceStatusBar import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog import io.nekohasekai.sfa.compose.navigation.SFANavHost import io.nekohasekai.sfa.compose.navigation.Screen import io.nekohasekai.sfa.compose.navigation.bottomNavigationScreens import io.nekohasekai.sfa.compose.screen.dashboard.CardGroup import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel +import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard +import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel import io.nekohasekai.sfa.compose.screen.log.LogViewModel import io.nekohasekai.sfa.compose.theme.SFATheme import io.nekohasekai.sfa.constant.Alert @@ -217,6 +236,9 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback { // Snackbar state val snackbarHostState = remember { SnackbarHostState() } + // Groups Sheet state + var showGroupsSheet by remember { mutableStateOf(false) } + // Error dialog state for UiEvent.ShowError var showErrorDialog by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf("") } @@ -493,29 +515,8 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback { } }, actions = { - // Show Groups and Others menu for Dashboard screen (but not in settings sub-screens) + // Show Others menu for Dashboard screen (but not in settings sub-screens) if (currentScreen == Screen.Dashboard && !isSettingsSubScreen) { - // Groups button - only show when service is running, groups exist, and Groups card is disabled - if ((currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting) && - dashboardUiState.hasGroups && - !dashboardUiState.visibleCards.contains(CardGroup.Groups) - ) { - IconButton(onClick = { - val intent = - Intent( - this@ComposeActivity, - GroupsComposeActivity::class.java, - ) - startActivity(intent) - }) { - Icon( - imageVector = Icons.Filled.Folder, - contentDescription = stringResource(R.string.title_groups), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } - // More options button IconButton(onClick = { dashboardViewModel.toggleCardSettingsDialog() }) { Icon( @@ -626,13 +627,123 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback { } }, ) { paddingValues -> - SFANavHost( - navController = navController, - serviceStatus = currentServiceStatus, - dashboardViewModel = dashboardViewModel, - logViewModel = logViewModel, - modifier = Modifier.padding(paddingValues), + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + // Service Status Bar (shown when service is running or stopping) + val serviceRunning = + currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting + val showStatusBar = serviceRunning || currentServiceStatus == Status.Stopping + val showStartFab = !serviceRunning && dashboardUiState.selectedProfileId != -1L + + SFANavHost( + navController = navController, + serviceStatus = currentServiceStatus, + showStartFab = showStartFab, + showStatusBar = showStatusBar, + dashboardViewModel = dashboardViewModel, + logViewModel = logViewModel, + modifier = Modifier.fillMaxSize(), + ) + ServiceStatusBar( + visible = showStatusBar && !isSettingsSubScreen, + serviceStatus = currentServiceStatus, + startTime = dashboardUiState.serviceStartTime, + groupsCount = dashboardUiState.groupsCount, + hasGroups = dashboardUiState.hasGroups, + onGroupsClick = { showGroupsSheet = true }, + onStopClick = { dashboardViewModel.toggleService() }, + modifier = Modifier.align(Alignment.BottomCenter), + ) + + // Start FAB (shown when service is not running and a profile is selected) + AnimatedVisibility( + visible = !serviceRunning && dashboardUiState.selectedProfileId != -1L && !isSettingsSubScreen, + enter = scaleIn(), + exit = scaleOut(), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + ) { + FloatingActionButton( + onClick = { startService() }, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = stringResource(R.string.action_start), + ) + } + } + } + } + + // Groups ModalBottomSheet + if (showGroupsSheet) { + val groupsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val groupsViewModel: GroupsViewModel = viewModel( + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return GroupsViewModel(dashboardViewModel.commandClient) as T + } + } ) + val groupsUiState by groupsViewModel.uiState.collectAsState() + val allCollapsed = groupsUiState.expandedGroups.isEmpty() + + ModalBottomSheet( + onDismissRequest = { showGroupsSheet = false }, + sheetState = groupsSheetState, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.9f), + ) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.title_groups), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (groupsUiState.groups.isNotEmpty()) { + IconButton(onClick = { groupsViewModel.toggleAllGroups() }) { + Icon( + imageVector = if (allCollapsed) Icons.Default.UnfoldMore + else Icons.Default.UnfoldLess, + contentDescription = if (allCollapsed) + stringResource(R.string.expand_all) + else + stringResource(R.string.collapse_all), + ) + } + } + } + + // Groups content + GroupsCard( + serviceStatus = currentServiceStatus, + commandClient = dashboardViewModel.commandClient, + viewModel = groupsViewModel, + modifier = Modifier.fillMaxSize(), + ) + } + } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/GroupsComposeActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/GroupsComposeActivity.kt index ece44ec..d1963b2 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/GroupsComposeActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/GroupsComposeActivity.kt @@ -101,7 +101,6 @@ class GroupsComposeActivity : ComponentActivity(), ServiceConnection.Callback { ) { paddingValues -> GroupsCard( serviceStatus = currentServiceStatus, - isCardMode = false, modifier = Modifier.padding(paddingValues), ) } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/component/ServiceStatusBar.kt b/app/src/main/java/io/nekohasekai/sfa/compose/component/ServiceStatusBar.kt new file mode 100644 index 0000000..5492034 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/component/ServiceStatusBar.kt @@ -0,0 +1,185 @@ +package io.nekohasekai.sfa.compose.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Stop +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.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.constant.Status +import kotlinx.coroutines.delay + +@Composable +fun ServiceStatusBar( + visible: Boolean, + serviceStatus: Status, + startTime: Long?, + groupsCount: Int, + hasGroups: Boolean, + onGroupsClick: () -> Unit, + onStopClick: () -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = visible, + enter = slideInVertically { it } + fadeIn(), + exit = slideOutVertically { it } + fadeOut(), + modifier = modifier, + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 3.dp, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Status text + StatusItem( + text = when (serviceStatus) { + Status.Starting -> stringResource(R.string.status_starting) + Status.Started -> stringResource(R.string.status_started) + Status.Stopping -> stringResource(R.string.status_stopping) + else -> "" + }, + modifier = Modifier.weight(1f), + ) + + // Groups button (only show if hasGroups) + if (hasGroups) { + Row( + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .clickable(onClick = onGroupsClick) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = groupsCount.toString(), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Default.Folder, + contentDescription = stringResource(R.string.title_groups), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + + // Uptime + if (startTime != null) { + UptimeDisplay(startTime = startTime) + } + + // Stop button + Row( + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primaryContainer) + .clickable(onClick = onStopClick) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Default.Stop, + contentDescription = stringResource(R.string.stop), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + } + } +} + +@Composable +private fun StatusItem( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + modifier = modifier, + ) +} + +@Composable +private fun UptimeDisplay( + startTime: Long, + modifier: Modifier = Modifier, +) { + var currentTime by remember { mutableLongStateOf(System.currentTimeMillis()) } + + LaunchedEffect(startTime) { + while (true) { + delay(1000) + currentTime = System.currentTimeMillis() + } + } + + val elapsedSeconds = ((currentTime - startTime) / 1000).coerceAtLeast(0) + val hours = elapsedSeconds / 3600 + val minutes = (elapsedSeconds % 3600) / 60 + val seconds = elapsedSeconds % 60 + + val formattedTime = + if (hours > 0) { + String.format("%d:%02d:%02d", hours, minutes, seconds) + } else { + String.format("%d:%02d", minutes, seconds) + } + + Text( + text = formattedTime, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier, + ) +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt index 3b11804..ec52e87 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt @@ -22,6 +22,8 @@ import io.nekohasekai.sfa.constant.Status fun SFANavHost( navController: NavHostController, serviceStatus: Status = Status.Stopped, + showStartFab: Boolean = false, + showStatusBar: Boolean = false, dashboardViewModel: DashboardViewModel? = null, logViewModel: LogViewModel? = null, modifier: Modifier = Modifier, @@ -46,10 +48,16 @@ fun SFANavHost( if (logViewModel != null) { LogScreen( serviceStatus = serviceStatus, + showStartFab = showStartFab, + showStatusBar = showStatusBar, viewModel = logViewModel, ) } else { - LogScreen(serviceStatus = serviceStatus) + LogScreen( + serviceStatus = serviceStatus, + showStartFab = showStartFab, + showStatusBar = showStatusBar, + ) } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt index 02d3ab2..f36fe71 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt @@ -131,16 +131,5 @@ fun DashboardCardRenderer( saveQRCodeToGallery = saveQRCodeToGallery, ) } - - CardGroup.Groups -> { - if (uiState.hasGroups) { - GroupsCard( - serviceStatus = serviceStatus, - isCardMode = true, - commandClient = commandClient, - modifier = modifier, - ) - } - } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt index 72ca592..5ef7444 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt @@ -1,6 +1,5 @@ package io.nekohasekai.sfa.compose.screen.dashboard -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -10,14 +9,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState @@ -244,55 +237,6 @@ fun DashboardScreen( } } } - - // FAB - AnimatedVisibility( - visible = uiState.serviceStatus != Status.Stopping, - enter = androidx.compose.animation.scaleIn(), - exit = androidx.compose.animation.scaleOut(), - modifier = - Modifier - .align(Alignment.BottomEnd) - .padding(16.dp), - ) { - ServiceControlFAB( - status = uiState.serviceStatus, - onToggle = { viewModel.toggleService() }, - enabled = uiState.selectedProfileId != -1L, - ) - } - } -} - -@Composable -fun ServiceControlFAB( - status: Status, - onToggle: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, -) { - FloatingActionButton( - onClick = { if (enabled) onToggle() }, - modifier = modifier, - containerColor = - if (enabled) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - ) { - Icon( - imageVector = - when (status) { - Status.Started, Status.Starting -> Icons.Default.Stop - else -> Icons.Default.PlayArrow - }, - contentDescription = - when (status) { - Status.Started, Status.Starting -> stringResource(R.string.stop) - else -> stringResource(R.string.action_start) - }, - ) } } @@ -369,6 +313,5 @@ fun isCardAvailableWhenServiceRunning( CardGroup.Connections -> uiState.trafficVisible CardGroup.SystemProxy -> uiState.systemProxyVisible CardGroup.Profiles -> true // This shouldn't be called for Profiles, but return true for safety - CardGroup.Groups -> uiState.hasGroups // Groups card available when groups exist } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardSettingsBottomSheet.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardSettingsBottomSheet.kt index 87018f4..13b6799 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardSettingsBottomSheet.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardSettingsBottomSheet.kt @@ -171,7 +171,6 @@ fun DashboardSettingsBottomSheet( CardGroup.SystemProxy, CardGroup.ClashMode, CardGroup.Profiles, - CardGroup.Groups, ) val allCardsEnabled = setOfNotNull( @@ -182,7 +181,6 @@ fun DashboardSettingsBottomSheet( CardGroup.Connections, CardGroup.SystemProxy, CardGroup.Profiles, - CardGroup.Groups, ) reorderedList = defaultOrder currentVisibleCards = allCardsEnabled @@ -396,7 +394,6 @@ fun DashboardItemCard( CardGroup.ClashMode -> Icons.Outlined.Route CardGroup.SystemProxy -> Icons.Outlined.SettingsEthernet CardGroup.Profiles -> Icons.Outlined.Person - CardGroup.Groups -> Icons.Outlined.Folder }, contentDescription = null, modifier = @@ -428,7 +425,6 @@ fun DashboardItemCard( CardGroup.ClashMode -> stringResource(R.string.clash_mode) CardGroup.SystemProxy -> stringResource(R.string.system_proxy) CardGroup.Profiles -> stringResource(R.string.title_configuration) - CardGroup.Groups -> stringResource(R.string.title_groups) }, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Medium, diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt index cbe9f7b..dc5d447 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt @@ -35,7 +35,6 @@ enum class CardGroup { Connections, SystemProxy, Profiles, - Groups, } enum class CardWidth { @@ -50,6 +49,8 @@ data class DashboardUiState( val selectedProfileName: String? = null, val isLoading: Boolean = false, val hasGroups: Boolean = false, + val groupsCount: Int = 0, + val serviceStartTime: Long? = null, val deprecatedNotes: List = emptyList(), val showDeprecatedDialog: Boolean = false, val showAddProfileSheet: Boolean = false, @@ -98,7 +99,6 @@ data class DashboardUiState( CardGroup.SystemProxy, CardGroup.ClashMode, CardGroup.Profiles, - CardGroup.Groups, ), val cardWidths: Map = mapOf( @@ -109,7 +109,6 @@ data class DashboardUiState( CardGroup.Connections to CardWidth.Half, CardGroup.SystemProxy to CardWidth.Full, CardGroup.Profiles to CardWidth.Full, - CardGroup.Groups to CardWidth.Full, ), val showCardSettingsDialog: Boolean = false, ) { @@ -143,17 +142,7 @@ class DashboardViewModel : BaseViewModel(), CommandCl // Calculate visible items (all items minus disabled) val allItems = CardGroup.values().toSet() - // Check if this is a first-time user (no saved order means never configured) - val isFirstTimeUser = Settings.dashboardItemOrder.isBlank() - val actualDisabledItems = - if (isFirstTimeUser && Settings.dashboardDisabledItems.isEmpty()) { - // First time user - Groups disabled by default - setOf(CardGroup.Groups) - } else { - // User has configured settings, respect their choices - disabledItems - } - val visibleCards = allItems - actualDisabledItems + val visibleCards = allItems - disabledItems return DashboardUiState( cardOrder = savedOrder, @@ -456,6 +445,9 @@ class DashboardViewModel : BaseViewModel(), CommandCl checkDeprecatedNotes() commandClient.connect() reloadSystemProxyStatus() + updateState { + copy(serviceStartTime = System.currentTimeMillis()) + } } Status.Stopped -> { @@ -463,6 +455,8 @@ class DashboardViewModel : BaseViewModel(), CommandCl updateState { copy( hasGroups = false, + groupsCount = 0, + serviceStartTime = null, clashModeVisible = false, systemProxyVisible = false, trafficVisible = false, @@ -618,7 +612,7 @@ class DashboardViewModel : BaseViewModel(), CommandCl viewModelScope.launch(Dispatchers.Main) { val hasGroups = newGroups.isNotEmpty() updateState { - copy(hasGroups = hasGroups) + copy(hasGroups = hasGroups, groupsCount = newGroups.size) } } } @@ -688,7 +682,6 @@ class DashboardViewModel : BaseViewModel(), CommandCl CardGroup.SystemProxy, CardGroup.ClashMode, CardGroup.Profiles, - CardGroup.Groups, ) private fun loadItemOrder(): List { diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/GroupsCard.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/GroupsCard.kt index e915449..95f1764 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/GroupsCard.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/GroupsCard.kt @@ -21,14 +21,13 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.Speed -import androidx.compose.material.icons.filled.UnfoldLess -import androidx.compose.material.icons.filled.UnfoldMore import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -49,9 +48,14 @@ import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Velocity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -68,40 +72,39 @@ import io.nekohasekai.sfa.utils.CommandClient @Composable fun GroupsCard( serviceStatus: Status, - isCardMode: Boolean = true, commandClient: CommandClient? = null, + viewModel: GroupsViewModel? = null, modifier: Modifier = Modifier, ) { - val viewModel: GroupsViewModel = - viewModel( - factory = - object : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - @Suppress("UNCHECKED_CAST") - return GroupsViewModel(commandClient) as T - } - }, - ) + val actualViewModel: GroupsViewModel = viewModel ?: viewModel( + factory = + object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return GroupsViewModel(commandClient) as T + } + }, + ) val snackbarHostState = remember { SnackbarHostState() } - val uiState by viewModel.uiState.collectAsState() + val uiState by actualViewModel.uiState.collectAsState() // Stable callbacks to prevent recomposition - use remember with viewModel as key val onToggleExpanded = - remember(viewModel) { - { groupTag: String -> viewModel.toggleGroupExpand(groupTag) } + remember(actualViewModel) { + { groupTag: String -> actualViewModel.toggleGroupExpand(groupTag) } } val onItemSelected = - remember(viewModel) { - { groupTag: String, itemTag: String -> viewModel.selectGroupItem(groupTag, itemTag) } + remember(actualViewModel) { + { groupTag: String, itemTag: String -> actualViewModel.selectGroupItem(groupTag, itemTag) } } val onUrlTest = - remember(viewModel) { - { groupTag: String -> viewModel.urlTest(groupTag) } + remember(actualViewModel) { + { groupTag: String -> actualViewModel.urlTest(groupTag) } } // Only update service status when it actually changes LaunchedEffect(serviceStatus) { - viewModel.updateServiceStatus(serviceStatus) + actualViewModel.updateServiceStatus(serviceStatus) } // Show snackbar when needed @@ -116,109 +119,34 @@ fun GroupsCard( ) when (result) { androidx.compose.material3.SnackbarResult.ActionPerformed -> { - viewModel.closeConnections() + actualViewModel.closeConnections() } androidx.compose.material3.SnackbarResult.Dismissed -> { - viewModel.dismissCloseConnectionsSnackbar() + actualViewModel.dismissCloseConnectionsSnackbar() } } } } - if (isCardMode) { - // Card mode - wrapped in a card with header - Card( - modifier = modifier.fillMaxWidth(), - ) { - GroupsCardContent( - uiState = uiState, - isCardMode = true, - onToggleAllGroups = { viewModel.toggleAllGroups() }, - onToggleExpanded = onToggleExpanded, - onItemSelected = onItemSelected, - onUrlTest = onUrlTest, - ) - } - } else { - // Standalone mode - direct content without card wrapper - GroupsCardContent( - uiState = uiState, - isCardMode = false, - onToggleAllGroups = { viewModel.toggleAllGroups() }, - onToggleExpanded = onToggleExpanded, - onItemSelected = onItemSelected, - onUrlTest = onUrlTest, - modifier = modifier, - ) - } + GroupsCardContent( + uiState = uiState, + onToggleExpanded = onToggleExpanded, + onItemSelected = onItemSelected, + onUrlTest = onUrlTest, + modifier = modifier, + ) } @Composable private fun GroupsCardContent( uiState: io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsUiState, - isCardMode: Boolean, - onToggleAllGroups: () -> Unit, onToggleExpanded: (String) -> Unit, onItemSelected: (String, String) -> Unit, onUrlTest: (String) -> Unit, modifier: Modifier = Modifier, ) { Column(modifier = modifier.fillMaxWidth()) { - if (isCardMode) { - // Card header with title and collapse/expand all button - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - imageVector = Icons.Default.FolderOpen, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Text( - text = stringResource(R.string.title_groups), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - } - - // Collapse/Expand all button in the top right - if (uiState.groups.isNotEmpty()) { - val allCollapsed = uiState.expandedGroups.isEmpty() - IconButton( - onClick = onToggleAllGroups, - modifier = Modifier.size(40.dp), - ) { - Icon( - imageVector = - if (allCollapsed) { - Icons.Default.UnfoldMore - } else { - Icons.Default.UnfoldLess - }, - contentDescription = if (allCollapsed) "Expand All" else "Collapse All", - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), - thickness = 1.dp, - ) - } - // Groups content if (uiState.isLoading) { Box( @@ -245,59 +173,34 @@ private fun GroupsCardContent( ) } } else { - if (isCardMode) { - // In card mode, show groups directly without LazyColumn - Column( - modifier = - Modifier - .fillMaxWidth(), - ) { - uiState.groups.forEachIndexed { index, group -> - // Add divider above each group (not for the first one in card mode) - if (index > 0) { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f), - thickness = 1.dp, - ) - } - ProxyGroupItem( - group = group, - isExpanded = uiState.expandedGroups.contains(group.tag), - onToggleExpanded = { onToggleExpanded(group.tag) }, - onItemSelected = { itemTag -> onItemSelected(group.tag, itemTag) }, - onUrlTest = { onUrlTest(group.tag) }, - showCard = false, - ) - } - } - } else { - // In standalone mode, use LazyColumn for scrolling - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = - PaddingValues( - start = 16.dp, - end = 16.dp, - top = 8.dp, - bottom = 16.dp, - ), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items( - items = uiState.groups, - key = { it.tag }, - contentType = { "GroupCard" }, - ) { group -> - ProxyGroupItem( - group = group, - isExpanded = uiState.expandedGroups.contains(group.tag), - onToggleExpanded = { onToggleExpanded(group.tag) }, - onItemSelected = { itemTag -> onItemSelected(group.tag, itemTag) }, - onUrlTest = { onUrlTest(group.tag) }, - showCard = true, - ) - } + val lazyListState = rememberLazyListState() + val bounceBlockingConnection = rememberBounceBlockingNestedScrollConnection(lazyListState) + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(bounceBlockingConnection), + state = lazyListState, + contentPadding = + PaddingValues( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = 16.dp, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + items = uiState.groups, + key = { it.tag }, + contentType = { "GroupCard" }, + ) { group -> + ProxyGroupItem( + group = group, + isExpanded = uiState.expandedGroups.contains(group.tag), + onToggleExpanded = { onToggleExpanded(group.tag) }, + onItemSelected = { itemTag -> onItemSelected(group.tag, itemTag) }, + onUrlTest = { onUrlTest(group.tag) }, + ) } } } @@ -312,9 +215,10 @@ private fun ProxyGroupItem( onToggleExpanded: () -> Unit, onItemSelected: (String) -> Unit, onUrlTest: () -> Unit, - showCard: Boolean, ) { - val content = @Composable { + Card( + modifier = Modifier.fillMaxWidth(), + ) { Column( modifier = Modifier.fillMaxWidth(), ) { @@ -459,16 +363,6 @@ private fun ProxyGroupItem( } } } - - if (showCard) { - Card( - modifier = Modifier.fillMaxWidth(), - ) { - content() - } - } else { - content() - } } @Composable @@ -693,3 +587,26 @@ private fun ProxyLatencyBadge( modifier = modifier, ) } + +@Composable +private fun rememberBounceBlockingNestedScrollConnection( + lazyListState: LazyListState +): NestedScrollConnection = remember(lazyListState) { + object : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + // Only block upward scroll (y < 0) at bottom to prevent sheet expansion + // Allow downward scroll (y > 0) at top to let sheet collapse + return if (available.y < 0) available else Offset.Zero + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + // Only block upward fling (y < 0) to prevent sheet expansion + // Allow downward fling (y > 0) to let sheet collapse + return if (available.y < 0) available else Velocity.Zero + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsViewModel.kt index 9b33f0e..b564346 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsViewModel.kt @@ -72,10 +72,10 @@ class GroupsViewModel( private fun handleServiceStatusChange(status: Status) { if (status == Status.Started) { - updateState { - copy(isLoading = true) - } if (!isUsingSharedClient) { + updateState { + copy(isLoading = true) + } connectionJob?.cancel() connectionJob = viewModelScope.launch(Dispatchers.IO) { while (isActive) { @@ -115,27 +115,42 @@ class GroupsViewModel( } fun toggleGroupExpand(groupTag: String) { + val newExpanded = !uiState.value.expandedGroups.contains(groupTag) updateState { - val newExpandedGroups = - if (expandedGroups.contains(groupTag)) { - expandedGroups - groupTag - } else { - expandedGroups + groupTag - } + val newExpandedGroups = if (newExpanded) { + expandedGroups + groupTag + } else { + expandedGroups - groupTag + } copy(expandedGroups = newExpandedGroups) } + viewModelScope.launch(Dispatchers.IO) { + runCatching { + Libbox.newStandaloneCommandClient().setGroupExpand(groupTag, newExpanded) + } + } } fun toggleAllGroups() { + val groups = uiState.value.groups + val allCollapsed = uiState.value.expandedGroups.isEmpty() + val newExpanded = allCollapsed + updateState { - if (expandedGroups.isEmpty()) { - // All are collapsed, expand all + if (allCollapsed) { copy(expandedGroups = groups.map { it.tag }.toSet()) } else { - // Some or all are expanded, collapse all copy(expandedGroups = emptySet()) } } + + viewModelScope.launch(Dispatchers.IO) { + groups.forEach { group -> + runCatching { + Libbox.newStandaloneCommandClient().setGroupExpand(group.tag, newExpanded) + } + } + } } fun selectGroupItem( @@ -292,9 +307,14 @@ class GroupsViewModel( withContext(Dispatchers.Main) { updateState { - // Keep existing expanded state when groups are updated + val initialExpandedGroups = if (expandedGroups.isEmpty() && currentGroups.isEmpty()) { + mergedGroups.filter { it.isExpand }.map { it.tag }.toSet() + } else { + expandedGroups + } copy( groups = mergedGroups, + expandedGroups = initialExpandedGroups, isLoading = false, ) } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt index bcde78b..a9eb4ec 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt @@ -43,13 +43,11 @@ import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.RadioButtonChecked import androidx.compose.material.icons.filled.RadioButtonUnchecked import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Share -import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenu @@ -88,8 +86,6 @@ import androidx.core.content.FileProvider import androidx.lifecycle.viewmodel.compose.viewModel import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.bg.BoxService -import io.nekohasekai.sfa.compose.ComposeActivity import io.nekohasekai.sfa.constant.Status import java.io.File import java.text.SimpleDateFormat @@ -100,6 +96,8 @@ import java.util.Locale @Composable fun LogScreen( serviceStatus: Status = Status.Stopped, + showStartFab: Boolean = false, + showStatusBar: Boolean = false, viewModel: LogViewModel = viewModel(), ) { val uiState by viewModel.uiState.collectAsState() @@ -374,6 +372,11 @@ fun LogScreen( } } else { // Log list + val extraBottomPadding = when { + showStartFab -> 72.dp + showStatusBar -> 58.dp + else -> 0.dp + } LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), @@ -382,7 +385,7 @@ fun LogScreen( start = 8.dp, end = 8.dp, top = 8.dp, - bottom = 88.dp, // Space for FAB + bottom = 88.dp + extraBottomPadding, // Space for FAB ), verticalArrangement = Arrangement.spacedBy(2.dp), ) { @@ -716,11 +719,16 @@ fun LogScreen( } // FABs - Hide during selection mode + val fabBottomPadding = when { + showStartFab -> 88.dp + showStatusBar -> 74.dp + else -> 16.dp + } Column( modifier = Modifier .align(Alignment.BottomEnd) - .padding(16.dp), + .padding(bottom = fabBottomPadding, end = 16.dp, top = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { // Scroll to bottom FAB @@ -732,7 +740,8 @@ fun LogScreen( ) { FloatingActionButton( onClick = { viewModel.scrollToBottom() }, - containerColor = MaterialTheme.colorScheme.secondary, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, ) { Icon( imageVector = Icons.Default.KeyboardArrowDown, @@ -740,38 +749,6 @@ fun LogScreen( ) } } - - // Start/Stop Service FAB - // Use fade animation on API 23 to avoid OpenGLRenderer crash with scale transforms - AnimatedVisibility( - visible = serviceStatus != Status.Stopping && !uiState.isSelectionMode, - enter = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) scaleIn() else fadeIn(), - exit = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) scaleOut() else fadeOut(), - ) { - FloatingActionButton( - onClick = { - when (serviceStatus) { - Status.Started, Status.Starting -> BoxService.stop() - Status.Stopped -> (context as ComposeActivity).startService() - else -> {} - } - }, - containerColor = MaterialTheme.colorScheme.primary, - ) { - Icon( - imageVector = - when (serviceStatus) { - Status.Started, Status.Starting -> Icons.Default.Stop - else -> Icons.Default.PlayArrow - }, - contentDescription = - when (serviceStatus) { - Status.Started, Status.Starting -> stringResource(R.string.stop) - else -> stringResource(R.string.action_start) - }, - ) - } - } } } // Close Box that contains Column, Options Menu and FAB } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 55c2f8a..dbf6b6d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -46,9 +46,9 @@ 删除 分享 服务未启动 - 服务启动中... - 服务停止中... - 服务已启动 + 启动中 + 停止中 + 已启动 启用 禁用 清理工作目录 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1ca8b5f..845bc83 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -85,9 +85,9 @@ Delete Share Service not started - Service starting… - Service stopping… - Service started + Starting + Stopping + Started Enabled Disabled Clear Working Directory