Refactor: groups

This commit is contained in:
世界
2025-12-27 12:55:27 +08:00
parent c053b7ef3a
commit d29b6255b7
13 changed files with 485 additions and 347 deletions

View File

@@ -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 <T : ViewModel> create(modelClass: Class<T>): 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(),
)
}
}
}
}

View File

@@ -101,7 +101,6 @@ class GroupsComposeActivity : ComponentActivity(), ServiceConnection.Callback {
) { paddingValues ->
GroupsCard(
serviceStatus = currentServiceStatus,
isCardMode = false,
modifier = Modifier.padding(paddingValues),
)
}

View File

@@ -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,
)
}

View File

@@ -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,
)
}
}

View File

@@ -131,16 +131,5 @@ fun DashboardCardRenderer(
saveQRCodeToGallery = saveQRCodeToGallery,
)
}
CardGroup.Groups -> {
if (uiState.hasGroups) {
GroupsCard(
serviceStatus = serviceStatus,
isCardMode = true,
commandClient = commandClient,
modifier = modifier,
)
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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,

View File

@@ -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<DeprecatedNote> = 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<CardGroup, CardWidth> =
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<DashboardUiState, UiEvent>(), 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<DashboardUiState, UiEvent>(), CommandCl
checkDeprecatedNotes()
commandClient.connect()
reloadSystemProxyStatus()
updateState {
copy(serviceStartTime = System.currentTimeMillis())
}
}
Status.Stopped -> {
@@ -463,6 +455,8 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
updateState {
copy(
hasGroups = false,
groupsCount = 0,
serviceStartTime = null,
clashModeVisible = false,
systemProxyVisible = false,
trafficVisible = false,
@@ -618,7 +612,7 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), 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<DashboardUiState, UiEvent>(), CommandCl
CardGroup.SystemProxy,
CardGroup.ClashMode,
CardGroup.Profiles,
CardGroup.Groups,
)
private fun loadItemOrder(): List<CardGroup> {

View File

@@ -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 <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return GroupsViewModel(commandClient) as T
}
},
)
val actualViewModel: GroupsViewModel = viewModel ?: viewModel(
factory =
object : ViewModelProvider.Factory {
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): 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
}
}
}

View File

@@ -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,
)
}

View File

@@ -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
}

View File

@@ -46,9 +46,9 @@
<string name="menu_delete">删除</string>
<string name="menu_share">分享</string>
<string name="status_default">服务未启动</string>
<string name="status_starting">服务启动中...</string>
<string name="status_stopping">服务停止中...</string>
<string name="status_started">服务已启动</string>
<string name="status_starting">启动中</string>
<string name="status_stopping">停止中</string>
<string name="status_started">已启动</string>
<string name="enabled">启用</string>
<string name="disabled">禁用</string>
<string name="settings_clear_working_directory">清理工作目录</string>

View File

@@ -85,9 +85,9 @@
<string name="menu_delete">Delete</string>
<string name="menu_share">Share</string>
<string name="status_default">Service not started</string>
<string name="status_starting">Service starting</string>
<string name="status_stopping">Service stopping</string>
<string name="status_started">Service started</string>
<string name="status_starting">Starting</string>
<string name="status_stopping">Stopping</string>
<string name="status_started">Started</string>
<string name="enabled">Enabled</string>
<string name="disabled">Disabled</string>
<string name="settings_clear_working_directory">Clear Working Directory</string>