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.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ExpandLess 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.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import dev.jeziellago.compose.markdowntext.MarkdownText import dev.jeziellago.compose.markdowntext.MarkdownText
@@ -37,15 +48,18 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SheetState
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -57,6 +71,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavDestination.Companion.hierarchy 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.bg.ServiceNotification
import io.nekohasekai.sfa.compose.base.GlobalEventBus import io.nekohasekai.sfa.compose.base.GlobalEventBus
import io.nekohasekai.sfa.compose.base.UiEvent 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.component.UpdateAvailableDialog
import io.nekohasekai.sfa.compose.navigation.SFANavHost import io.nekohasekai.sfa.compose.navigation.SFANavHost
import io.nekohasekai.sfa.compose.navigation.Screen import io.nekohasekai.sfa.compose.navigation.Screen
import io.nekohasekai.sfa.compose.navigation.bottomNavigationScreens import io.nekohasekai.sfa.compose.navigation.bottomNavigationScreens
import io.nekohasekai.sfa.compose.screen.dashboard.CardGroup import io.nekohasekai.sfa.compose.screen.dashboard.CardGroup
import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel 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.screen.log.LogViewModel
import io.nekohasekai.sfa.compose.theme.SFATheme import io.nekohasekai.sfa.compose.theme.SFATheme
import io.nekohasekai.sfa.constant.Alert import io.nekohasekai.sfa.constant.Alert
@@ -217,6 +236,9 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback {
// Snackbar state // Snackbar state
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
// Groups Sheet state
var showGroupsSheet by remember { mutableStateOf(false) }
// Error dialog state for UiEvent.ShowError // Error dialog state for UiEvent.ShowError
var showErrorDialog by remember { mutableStateOf(false) } var showErrorDialog by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") } var errorMessage by remember { mutableStateOf("") }
@@ -493,29 +515,8 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback {
} }
}, },
actions = { 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) { 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 // More options button
IconButton(onClick = { dashboardViewModel.toggleCardSettingsDialog() }) { IconButton(onClick = { dashboardViewModel.toggleCardSettingsDialog() }) {
Icon( Icon(
@@ -626,13 +627,123 @@ class ComposeActivity : ComponentActivity(), ServiceConnection.Callback {
} }
}, },
) { paddingValues -> ) { paddingValues ->
SFANavHost( Box(
navController = navController, modifier = Modifier
serviceStatus = currentServiceStatus, .fillMaxSize()
dashboardViewModel = dashboardViewModel, .padding(paddingValues),
logViewModel = logViewModel, ) {
modifier = Modifier.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 -> ) { paddingValues ->
GroupsCard( GroupsCard(
serviceStatus = currentServiceStatus, serviceStatus = currentServiceStatus,
isCardMode = false,
modifier = Modifier.padding(paddingValues), 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( fun SFANavHost(
navController: NavHostController, navController: NavHostController,
serviceStatus: Status = Status.Stopped, serviceStatus: Status = Status.Stopped,
showStartFab: Boolean = false,
showStatusBar: Boolean = false,
dashboardViewModel: DashboardViewModel? = null, dashboardViewModel: DashboardViewModel? = null,
logViewModel: LogViewModel? = null, logViewModel: LogViewModel? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -46,10 +48,16 @@ fun SFANavHost(
if (logViewModel != null) { if (logViewModel != null) {
LogScreen( LogScreen(
serviceStatus = serviceStatus, serviceStatus = serviceStatus,
showStartFab = showStartFab,
showStatusBar = showStatusBar,
viewModel = logViewModel, viewModel = logViewModel,
) )
} else { } else {
LogScreen(serviceStatus = serviceStatus) LogScreen(
serviceStatus = serviceStatus,
showStartFab = showStartFab,
showStatusBar = showStatusBar,
)
} }
} }

View File

@@ -131,16 +131,5 @@ fun DashboardCardRenderer(
saveQRCodeToGallery = saveQRCodeToGallery, 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 package io.nekohasekai.sfa.compose.screen.dashboard
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues 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.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api 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.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState 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.Connections -> uiState.trafficVisible
CardGroup.SystemProxy -> uiState.systemProxyVisible CardGroup.SystemProxy -> uiState.systemProxyVisible
CardGroup.Profiles -> true // This shouldn't be called for Profiles, but return true for safety 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.SystemProxy,
CardGroup.ClashMode, CardGroup.ClashMode,
CardGroup.Profiles, CardGroup.Profiles,
CardGroup.Groups,
) )
val allCardsEnabled = val allCardsEnabled =
setOfNotNull( setOfNotNull(
@@ -182,7 +181,6 @@ fun DashboardSettingsBottomSheet(
CardGroup.Connections, CardGroup.Connections,
CardGroup.SystemProxy, CardGroup.SystemProxy,
CardGroup.Profiles, CardGroup.Profiles,
CardGroup.Groups,
) )
reorderedList = defaultOrder reorderedList = defaultOrder
currentVisibleCards = allCardsEnabled currentVisibleCards = allCardsEnabled
@@ -396,7 +394,6 @@ fun DashboardItemCard(
CardGroup.ClashMode -> Icons.Outlined.Route CardGroup.ClashMode -> Icons.Outlined.Route
CardGroup.SystemProxy -> Icons.Outlined.SettingsEthernet CardGroup.SystemProxy -> Icons.Outlined.SettingsEthernet
CardGroup.Profiles -> Icons.Outlined.Person CardGroup.Profiles -> Icons.Outlined.Person
CardGroup.Groups -> Icons.Outlined.Folder
}, },
contentDescription = null, contentDescription = null,
modifier = modifier =
@@ -428,7 +425,6 @@ fun DashboardItemCard(
CardGroup.ClashMode -> stringResource(R.string.clash_mode) CardGroup.ClashMode -> stringResource(R.string.clash_mode)
CardGroup.SystemProxy -> stringResource(R.string.system_proxy) CardGroup.SystemProxy -> stringResource(R.string.system_proxy)
CardGroup.Profiles -> stringResource(R.string.title_configuration) CardGroup.Profiles -> stringResource(R.string.title_configuration)
CardGroup.Groups -> stringResource(R.string.title_groups)
}, },
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,

View File

@@ -35,7 +35,6 @@ enum class CardGroup {
Connections, Connections,
SystemProxy, SystemProxy,
Profiles, Profiles,
Groups,
} }
enum class CardWidth { enum class CardWidth {
@@ -50,6 +49,8 @@ data class DashboardUiState(
val selectedProfileName: String? = null, val selectedProfileName: String? = null,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val hasGroups: Boolean = false, val hasGroups: Boolean = false,
val groupsCount: Int = 0,
val serviceStartTime: Long? = null,
val deprecatedNotes: List<DeprecatedNote> = emptyList(), val deprecatedNotes: List<DeprecatedNote> = emptyList(),
val showDeprecatedDialog: Boolean = false, val showDeprecatedDialog: Boolean = false,
val showAddProfileSheet: Boolean = false, val showAddProfileSheet: Boolean = false,
@@ -98,7 +99,6 @@ data class DashboardUiState(
CardGroup.SystemProxy, CardGroup.SystemProxy,
CardGroup.ClashMode, CardGroup.ClashMode,
CardGroup.Profiles, CardGroup.Profiles,
CardGroup.Groups,
), ),
val cardWidths: Map<CardGroup, CardWidth> = val cardWidths: Map<CardGroup, CardWidth> =
mapOf( mapOf(
@@ -109,7 +109,6 @@ data class DashboardUiState(
CardGroup.Connections to CardWidth.Half, CardGroup.Connections to CardWidth.Half,
CardGroup.SystemProxy to CardWidth.Full, CardGroup.SystemProxy to CardWidth.Full,
CardGroup.Profiles to CardWidth.Full, CardGroup.Profiles to CardWidth.Full,
CardGroup.Groups to CardWidth.Full,
), ),
val showCardSettingsDialog: Boolean = false, val showCardSettingsDialog: Boolean = false,
) { ) {
@@ -143,17 +142,7 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
// Calculate visible items (all items minus disabled) // Calculate visible items (all items minus disabled)
val allItems = CardGroup.values().toSet() val allItems = CardGroup.values().toSet()
// Check if this is a first-time user (no saved order means never configured) val visibleCards = allItems - disabledItems
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
return DashboardUiState( return DashboardUiState(
cardOrder = savedOrder, cardOrder = savedOrder,
@@ -456,6 +445,9 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
checkDeprecatedNotes() checkDeprecatedNotes()
commandClient.connect() commandClient.connect()
reloadSystemProxyStatus() reloadSystemProxyStatus()
updateState {
copy(serviceStartTime = System.currentTimeMillis())
}
} }
Status.Stopped -> { Status.Stopped -> {
@@ -463,6 +455,8 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
updateState { updateState {
copy( copy(
hasGroups = false, hasGroups = false,
groupsCount = 0,
serviceStartTime = null,
clashModeVisible = false, clashModeVisible = false,
systemProxyVisible = false, systemProxyVisible = false,
trafficVisible = false, trafficVisible = false,
@@ -618,7 +612,7 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
viewModelScope.launch(Dispatchers.Main) { viewModelScope.launch(Dispatchers.Main) {
val hasGroups = newGroups.isNotEmpty() val hasGroups = newGroups.isNotEmpty()
updateState { updateState {
copy(hasGroups = hasGroups) copy(hasGroups = hasGroups, groupsCount = newGroups.size)
} }
} }
} }
@@ -688,7 +682,6 @@ class DashboardViewModel : BaseViewModel<DashboardUiState, UiEvent>(), CommandCl
CardGroup.SystemProxy, CardGroup.SystemProxy,
CardGroup.ClashMode, CardGroup.ClashMode,
CardGroup.Profiles, CardGroup.Profiles,
CardGroup.Groups,
) )
private fun loadItemOrder(): List<CardGroup> { 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore 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.Speed
import androidx.compose.material.icons.filled.UnfoldLess
import androidx.compose.material.icons.filled.UnfoldMore
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -49,9 +48,14 @@ import androidx.compose.runtime.key
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer 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.res.stringResource
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -68,40 +72,39 @@ import io.nekohasekai.sfa.utils.CommandClient
@Composable @Composable
fun GroupsCard( fun GroupsCard(
serviceStatus: Status, serviceStatus: Status,
isCardMode: Boolean = true,
commandClient: CommandClient? = null, commandClient: CommandClient? = null,
viewModel: GroupsViewModel? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val viewModel: GroupsViewModel = val actualViewModel: GroupsViewModel = viewModel ?: viewModel(
viewModel( factory =
factory = object : ViewModelProvider.Factory {
object : ViewModelProvider.Factory { override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T { @Suppress("UNCHECKED_CAST")
@Suppress("UNCHECKED_CAST") return GroupsViewModel(commandClient) as T
return GroupsViewModel(commandClient) as T }
} },
}, )
)
val snackbarHostState = remember { SnackbarHostState() } 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 // Stable callbacks to prevent recomposition - use remember with viewModel as key
val onToggleExpanded = val onToggleExpanded =
remember(viewModel) { remember(actualViewModel) {
{ groupTag: String -> viewModel.toggleGroupExpand(groupTag) } { groupTag: String -> actualViewModel.toggleGroupExpand(groupTag) }
} }
val onItemSelected = val onItemSelected =
remember(viewModel) { remember(actualViewModel) {
{ groupTag: String, itemTag: String -> viewModel.selectGroupItem(groupTag, itemTag) } { groupTag: String, itemTag: String -> actualViewModel.selectGroupItem(groupTag, itemTag) }
} }
val onUrlTest = val onUrlTest =
remember(viewModel) { remember(actualViewModel) {
{ groupTag: String -> viewModel.urlTest(groupTag) } { groupTag: String -> actualViewModel.urlTest(groupTag) }
} }
// Only update service status when it actually changes // Only update service status when it actually changes
LaunchedEffect(serviceStatus) { LaunchedEffect(serviceStatus) {
viewModel.updateServiceStatus(serviceStatus) actualViewModel.updateServiceStatus(serviceStatus)
} }
// Show snackbar when needed // Show snackbar when needed
@@ -116,109 +119,34 @@ fun GroupsCard(
) )
when (result) { when (result) {
androidx.compose.material3.SnackbarResult.ActionPerformed -> { androidx.compose.material3.SnackbarResult.ActionPerformed -> {
viewModel.closeConnections() actualViewModel.closeConnections()
} }
androidx.compose.material3.SnackbarResult.Dismissed -> { androidx.compose.material3.SnackbarResult.Dismissed -> {
viewModel.dismissCloseConnectionsSnackbar() actualViewModel.dismissCloseConnectionsSnackbar()
} }
} }
} }
} }
if (isCardMode) { GroupsCardContent(
// Card mode - wrapped in a card with header uiState = uiState,
Card( onToggleExpanded = onToggleExpanded,
modifier = modifier.fillMaxWidth(), onItemSelected = onItemSelected,
) { onUrlTest = onUrlTest,
GroupsCardContent( modifier = modifier,
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,
)
}
} }
@Composable @Composable
private fun GroupsCardContent( private fun GroupsCardContent(
uiState: io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsUiState, uiState: io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsUiState,
isCardMode: Boolean,
onToggleAllGroups: () -> Unit,
onToggleExpanded: (String) -> Unit, onToggleExpanded: (String) -> Unit,
onItemSelected: (String, String) -> Unit, onItemSelected: (String, String) -> Unit,
onUrlTest: (String) -> Unit, onUrlTest: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier = modifier.fillMaxWidth()) { 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 // Groups content
if (uiState.isLoading) { if (uiState.isLoading) {
Box( Box(
@@ -245,59 +173,34 @@ private fun GroupsCardContent(
) )
} }
} else { } else {
if (isCardMode) { val lazyListState = rememberLazyListState()
// In card mode, show groups directly without LazyColumn val bounceBlockingConnection = rememberBounceBlockingNestedScrollConnection(lazyListState)
Column( LazyColumn(
modifier = modifier = Modifier
Modifier .fillMaxSize()
.fillMaxWidth(), .nestedScroll(bounceBlockingConnection),
) { state = lazyListState,
uiState.groups.forEachIndexed { index, group -> contentPadding =
// Add divider above each group (not for the first one in card mode) PaddingValues(
if (index > 0) { start = 16.dp,
HorizontalDivider( end = 16.dp,
modifier = Modifier.padding(horizontal = 16.dp), top = 8.dp,
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f), bottom = 16.dp,
thickness = 1.dp, ),
) verticalArrangement = Arrangement.spacedBy(12.dp),
} ) {
ProxyGroupItem( items(
group = group, items = uiState.groups,
isExpanded = uiState.expandedGroups.contains(group.tag), key = { it.tag },
onToggleExpanded = { onToggleExpanded(group.tag) }, contentType = { "GroupCard" },
onItemSelected = { itemTag -> onItemSelected(group.tag, itemTag) }, ) { group ->
onUrlTest = { onUrlTest(group.tag) }, ProxyGroupItem(
showCard = false, group = group,
) isExpanded = uiState.expandedGroups.contains(group.tag),
} onToggleExpanded = { onToggleExpanded(group.tag) },
} onItemSelected = { itemTag -> onItemSelected(group.tag, itemTag) },
} else { onUrlTest = { onUrlTest(group.tag) },
// 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,
)
}
} }
} }
} }
@@ -312,9 +215,10 @@ private fun ProxyGroupItem(
onToggleExpanded: () -> Unit, onToggleExpanded: () -> Unit,
onItemSelected: (String) -> Unit, onItemSelected: (String) -> Unit,
onUrlTest: () -> Unit, onUrlTest: () -> Unit,
showCard: Boolean,
) { ) {
val content = @Composable { Card(
modifier = Modifier.fillMaxWidth(),
) {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
@@ -459,16 +363,6 @@ private fun ProxyGroupItem(
} }
} }
} }
if (showCard) {
Card(
modifier = Modifier.fillMaxWidth(),
) {
content()
}
} else {
content()
}
} }
@Composable @Composable
@@ -693,3 +587,26 @@ private fun ProxyLatencyBadge(
modifier = modifier, 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) { private fun handleServiceStatusChange(status: Status) {
if (status == Status.Started) { if (status == Status.Started) {
updateState {
copy(isLoading = true)
}
if (!isUsingSharedClient) { if (!isUsingSharedClient) {
updateState {
copy(isLoading = true)
}
connectionJob?.cancel() connectionJob?.cancel()
connectionJob = viewModelScope.launch(Dispatchers.IO) { connectionJob = viewModelScope.launch(Dispatchers.IO) {
while (isActive) { while (isActive) {
@@ -115,27 +115,42 @@ class GroupsViewModel(
} }
fun toggleGroupExpand(groupTag: String) { fun toggleGroupExpand(groupTag: String) {
val newExpanded = !uiState.value.expandedGroups.contains(groupTag)
updateState { updateState {
val newExpandedGroups = val newExpandedGroups = if (newExpanded) {
if (expandedGroups.contains(groupTag)) { expandedGroups + groupTag
expandedGroups - groupTag } else {
} else { expandedGroups - groupTag
expandedGroups + groupTag }
}
copy(expandedGroups = newExpandedGroups) copy(expandedGroups = newExpandedGroups)
} }
viewModelScope.launch(Dispatchers.IO) {
runCatching {
Libbox.newStandaloneCommandClient().setGroupExpand(groupTag, newExpanded)
}
}
} }
fun toggleAllGroups() { fun toggleAllGroups() {
val groups = uiState.value.groups
val allCollapsed = uiState.value.expandedGroups.isEmpty()
val newExpanded = allCollapsed
updateState { updateState {
if (expandedGroups.isEmpty()) { if (allCollapsed) {
// All are collapsed, expand all
copy(expandedGroups = groups.map { it.tag }.toSet()) copy(expandedGroups = groups.map { it.tag }.toSet())
} else { } else {
// Some or all are expanded, collapse all
copy(expandedGroups = emptySet()) copy(expandedGroups = emptySet())
} }
} }
viewModelScope.launch(Dispatchers.IO) {
groups.forEach { group ->
runCatching {
Libbox.newStandaloneCommandClient().setGroupExpand(group.tag, newExpanded)
}
}
}
} }
fun selectGroupItem( fun selectGroupItem(
@@ -292,9 +307,14 @@ class GroupsViewModel(
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
updateState { 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( copy(
groups = mergedGroups, groups = mergedGroups,
expandedGroups = initialExpandedGroups,
isLoading = false, 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.ExpandMore
import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.KeyboardArrowDown 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.RadioButtonChecked
import androidx.compose.material.icons.filled.RadioButtonUnchecked import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
@@ -88,8 +86,6 @@ import androidx.core.content.FileProvider
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.Application
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.BoxService
import io.nekohasekai.sfa.compose.ComposeActivity
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -100,6 +96,8 @@ import java.util.Locale
@Composable @Composable
fun LogScreen( fun LogScreen(
serviceStatus: Status = Status.Stopped, serviceStatus: Status = Status.Stopped,
showStartFab: Boolean = false,
showStatusBar: Boolean = false,
viewModel: LogViewModel = viewModel(), viewModel: LogViewModel = viewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
@@ -374,6 +372,11 @@ fun LogScreen(
} }
} else { } else {
// Log list // Log list
val extraBottomPadding = when {
showStartFab -> 72.dp
showStatusBar -> 58.dp
else -> 0.dp
}
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -382,7 +385,7 @@ fun LogScreen(
start = 8.dp, start = 8.dp,
end = 8.dp, end = 8.dp,
top = 8.dp, top = 8.dp,
bottom = 88.dp, // Space for FAB bottom = 88.dp + extraBottomPadding, // Space for FAB
), ),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
@@ -716,11 +719,16 @@ fun LogScreen(
} }
// FABs - Hide during selection mode // FABs - Hide during selection mode
val fabBottomPadding = when {
showStartFab -> 88.dp
showStatusBar -> 74.dp
else -> 16.dp
}
Column( Column(
modifier = modifier =
Modifier Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(16.dp), .padding(bottom = fabBottomPadding, end = 16.dp, top = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
// Scroll to bottom FAB // Scroll to bottom FAB
@@ -732,7 +740,8 @@ fun LogScreen(
) { ) {
FloatingActionButton( FloatingActionButton(
onClick = { viewModel.scrollToBottom() }, onClick = { viewModel.scrollToBottom() },
containerColor = MaterialTheme.colorScheme.secondary, containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
) { ) {
Icon( Icon(
imageVector = Icons.Default.KeyboardArrowDown, 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 } // Close Box that contains Column, Options Menu and FAB
} }

View File

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

View File

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