Refine tablet UI and QRS layout

This commit is contained in:
世界
2026-01-06 19:19:20 +08:00
parent a954ec306c
commit aa7b996688
11 changed files with 988 additions and 479 deletions

View File

@@ -3,6 +3,7 @@ package io.nekohasekai.sfa.compose
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.content.res.Configuration
import android.net.VpnService import android.net.VpnService
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -15,6 +16,7 @@ import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
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.fillMaxHeight 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.fillMaxWidth
@@ -22,35 +24,31 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.ui.text.font.FontWeight 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.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.MoreVert 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.SwapVert import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.UnfoldLess import androidx.compose.material.icons.filled.UnfoldLess
import androidx.compose.material.icons.filled.UnfoldMore 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.height
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.FloatingActionButton
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilterChip
import dev.jeziellago.compose.markdowntext.MarkdownText import dev.jeziellago.compose.markdowntext.MarkdownText
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import androidx.compose.material3.BadgedBox import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.ExperimentalMaterial3Api 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
@@ -58,8 +56,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet 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.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SheetState import androidx.compose.material3.Surface
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
@@ -76,6 +76,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
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.ViewModel
@@ -95,6 +96,7 @@ 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.ServiceStatusBar
import io.nekohasekai.sfa.compose.component.UptimeText
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
@@ -103,11 +105,8 @@ 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.GroupsCard
import io.nekohasekai.sfa.compose.screen.connections.ConnectionDetailsScreen import io.nekohasekai.sfa.compose.screen.connections.ConnectionDetailsScreen
import io.nekohasekai.sfa.compose.screen.connections.ConnectionsScreen import io.nekohasekai.sfa.compose.screen.connections.ConnectionsPage
import io.nekohasekai.sfa.compose.screen.connections.ConnectionsViewModel import io.nekohasekai.sfa.compose.screen.connections.ConnectionsViewModel
import io.nekohasekai.sfa.compose.model.Connection
import io.nekohasekai.sfa.compose.model.ConnectionSort
import io.nekohasekai.sfa.compose.model.ConnectionStateFilter
import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel 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
@@ -268,8 +267,13 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
val navController = rememberNavController() val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination val currentDestination = navBackStackEntry?.destination
val currentRoute = currentDestination?.route
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val configuration = LocalConfiguration.current
val useNavigationRail =
configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE)
// Snackbar state // Snackbar state
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -490,16 +494,32 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
} }
val dashboardUiState by dashboardViewModel.uiState.collectAsState() val dashboardUiState by dashboardViewModel.uiState.collectAsState()
val isSettingsSubScreen = currentRoute?.startsWith("settings/") == true
val isConnectionsDetail = currentRoute?.startsWith("connections/detail") == true
val currentRootRoute =
when {
isSettingsSubScreen -> Screen.Settings.route
currentRoute?.startsWith(Screen.Connections.route) == true -> Screen.Connections.route
currentRoute?.startsWith(Screen.Log.route) == true -> Screen.Log.route
else -> currentRoute
}
val isConnectionsRoute = currentRootRoute == Screen.Connections.route
val isGroupsRoute = currentRootRoute == Screen.Groups.route
// Determine current screen title // Determine current screen title
val currentScreen = val currentScreen =
bottomNavigationScreens.find { screen -> when (currentRootRoute) {
currentDestination?.route == screen.route Screen.Dashboard.route -> Screen.Dashboard
} ?: bottomNavigationScreens[0] Screen.Groups.route -> Screen.Groups
Screen.Connections.route -> Screen.Connections
Screen.Log.route -> Screen.Log
Screen.Settings.route -> Screen.Settings
else -> Screen.Dashboard
}
// Check if we're in a settings sub-screen val isSubScreen = isSettingsSubScreen || isConnectionsDetail
val isSettingsSubScreen = currentDestination?.route?.startsWith("settings/") == true
val settingsScreenTitle = val settingsScreenTitle =
when (currentDestination?.route) { when (currentRoute) {
"settings/app" -> stringResource(R.string.title_app_settings) "settings/app" -> stringResource(R.string.title_app_settings)
"settings/core" -> stringResource(R.string.core) "settings/core" -> stringResource(R.string.core)
"settings/service" -> stringResource(R.string.service) "settings/service" -> stringResource(R.string.service)
@@ -515,6 +535,76 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
null null
} }
val groupsViewModel: GroupsViewModel? =
if (isGroupsRoute) {
viewModel(
factory = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return GroupsViewModel(dashboardViewModel.commandClient) as T
}
}
)
} else {
null
}
val connectionsViewModel: ConnectionsViewModel? =
if (isConnectionsRoute) {
viewModel(
factory = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return ConnectionsViewModel(dashboardViewModel.commandClient) as T
}
}
)
} else {
null
}
val showGroupsInNav = dashboardUiState.hasGroups
val showConnectionsInNav =
currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting
val railScreens =
buildList {
add(Screen.Dashboard)
if (showGroupsInNav) {
add(Screen.Groups)
}
if (showConnectionsInNav) {
add(Screen.Connections)
}
add(Screen.Log)
add(Screen.Settings)
}
val allowedRoutes =
buildSet {
add(Screen.Dashboard.route)
add(Screen.Log.route)
add(Screen.Settings.route)
if (useNavigationRail && showGroupsInNav) {
add(Screen.Groups.route)
}
if (useNavigationRail && showConnectionsInNav) {
add(Screen.Connections.route)
}
}
LaunchedEffect(allowedRoutes, currentRootRoute, useNavigationRail) {
if (currentRootRoute != null && !allowedRoutes.contains(currentRootRoute)) {
navController.navigate(Screen.Dashboard.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
}
// Collect all UI events from GlobalEventBus // Collect all UI events from GlobalEventBus
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
GlobalEventBus.events.collect { event -> GlobalEventBus.events.collect { event ->
@@ -565,143 +655,137 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
} }
} }
Scaffold( val topBarContent: @Composable () -> Unit = {
modifier = Modifier.fillMaxSize(), TopAppBar(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, title = {
topBar = { Text(
TopAppBar( when {
title = { isSettingsSubScreen && settingsScreenTitle != null -> settingsScreenTitle
Text( isConnectionsDetail -> stringResource(R.string.connection_details)
if (isSettingsSubScreen && settingsScreenTitle != null) { else -> stringResource(currentScreen.titleRes)
settingsScreenTitle },
} else { )
stringResource(currentScreen.titleRes) },
}, navigationIcon = {
) if (isSubScreen) {
}, IconButton(onClick = { navController.navigateUp() }) {
navigationIcon = { Icon(
if (isSettingsSubScreen) { imageVector = Icons.AutoMirrored.Default.ArrowBack,
IconButton(onClick = { navController.navigateUp() }) { contentDescription = stringResource(R.string.content_description_back),
)
}
}
},
actions = {
// Show Others menu for Dashboard screen (but not in settings sub-screens)
if (currentScreen == Screen.Dashboard && !isSettingsSubScreen) {
// More options button
IconButton(onClick = { dashboardViewModel.toggleCardSettingsDialog() }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.title_others),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
if (currentScreen == Screen.Groups && groupsViewModel != null) {
val groupsUiState by groupsViewModel.uiState.collectAsState()
val allCollapsed = groupsUiState.expandedGroups.isEmpty()
if (groupsUiState.groups.isNotEmpty()) {
IconButton(onClick = { groupsViewModel.toggleAllGroups() }) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack, imageVector = if (allCollapsed) Icons.Default.UnfoldMore else Icons.Default.UnfoldLess,
contentDescription = stringResource(R.string.content_description_back), contentDescription =
if (allCollapsed) {
stringResource(R.string.expand_all)
} else {
stringResource(R.string.collapse_all)
},
) )
} }
} }
}, }
actions = {
// Show Others menu for Dashboard screen (but not in settings sub-screens) if (isConnectionsDetail && connectionsViewModel != null) {
if (currentScreen == Screen.Dashboard && !isSettingsSubScreen) { val connectionsUiState by connectionsViewModel.uiState.collectAsState()
// More options button val connectionId = navBackStackEntry?.arguments?.getString("connectionId")
IconButton(onClick = { dashboardViewModel.toggleCardSettingsDialog() }) { val detailConnection =
connectionsUiState.allConnections.find { it.id == connectionId }
?: connectionsUiState.connections.find { it.id == connectionId }
if (detailConnection?.isActive == true) {
IconButton(onClick = { connectionsViewModel.closeConnection(detailConnection.id) }) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.connection_close),
)
}
}
}
if (currentScreen == Screen.Log && logViewModel != null) {
val logUiState by logViewModel.uiState.collectAsState()
if (!logUiState.isSelectionMode) {
IconButton(onClick = { logViewModel.togglePause() }) {
Icon(
imageVector =
if (logUiState.isPaused) {
Icons.Default.PlayArrow
} else {
Icons.Default.Pause
},
contentDescription =
if (logUiState.isPaused) {
stringResource(
R.string.content_description_resume_logs,
)
} else {
stringResource(R.string.content_description_pause_logs)
},
)
}
IconButton(onClick = { logViewModel.toggleSearch() }) {
Icon(
imageVector =
if (logUiState.isSearchActive) {
Icons.Default.ExpandLess
} else {
Icons.Default.Search
},
contentDescription =
if (logUiState.isSearchActive) {
stringResource(
R.string.content_description_collapse_search,
)
} else {
stringResource(R.string.content_description_search_logs)
},
tint =
if (logUiState.isSearchActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
)
}
IconButton(onClick = { logViewModel.toggleOptionsMenu() }) {
Icon( Icon(
imageVector = Icons.Default.MoreVert, imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.title_others), contentDescription = stringResource(R.string.more_options),
tint = MaterialTheme.colorScheme.onSurface, tint = MaterialTheme.colorScheme.onSurface,
) )
} }
} }
if (currentScreen == Screen.Log && logViewModel != null) {
val logUiState by logViewModel.uiState.collectAsState()
if (!logUiState.isSelectionMode) {
IconButton(onClick = { logViewModel.togglePause() }) {
Icon(
imageVector =
if (logUiState.isPaused) {
Icons.Default.PlayArrow
} else {
Icons.Default.Pause
},
contentDescription =
if (logUiState.isPaused) {
stringResource(
R.string.content_description_resume_logs,
)
} else {
stringResource(R.string.content_description_pause_logs)
},
)
}
IconButton(onClick = { logViewModel.toggleSearch() }) {
Icon(
imageVector =
if (logUiState.isSearchActive) {
Icons.Default.ExpandLess
} else {
Icons.Default.Search
},
contentDescription =
if (logUiState.isSearchActive) {
stringResource(
R.string.content_description_collapse_search,
)
} else {
stringResource(R.string.content_description_search_logs)
},
tint =
if (logUiState.isSearchActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
)
}
IconButton(onClick = { logViewModel.toggleOptionsMenu() }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.more_options),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(),
)
},
bottomBar = {
// Only show bottom bar when not in settings sub-screens
if (!isSettingsSubScreen) {
val hasUpdate by UpdateState.hasUpdate
NavigationBar {
bottomNavigationScreens.forEach { screen ->
NavigationBarItem(
icon = {
if (screen == Screen.Settings && hasUpdate) {
BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) {
Icon(screen.icon, contentDescription = null)
}
} else {
Icon(screen.icon, contentDescription = null)
}
},
selected =
currentDestination?.hierarchy?.any {
it.route == screen.route
} == true,
onClick = {
navController.navigate(screen.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
},
)
}
} }
} },
}, colors = TopAppBarDefaults.topAppBarColors(),
) { paddingValues -> )
}
val scaffoldContent: @Composable (PaddingValues) -> Unit = { paddingValues ->
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -720,46 +804,231 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
showStatusBar = showStatusBar, showStatusBar = showStatusBar,
dashboardViewModel = dashboardViewModel, dashboardViewModel = dashboardViewModel,
logViewModel = logViewModel, logViewModel = logViewModel,
groupsViewModel = groupsViewModel,
connectionsViewModel = connectionsViewModel,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
ServiceStatusBar( if (!useNavigationRail) {
visible = showStatusBar && !isSettingsSubScreen, ServiceStatusBar(
serviceStatus = currentServiceStatus, visible = showStatusBar && !isSubScreen,
startTime = dashboardUiState.serviceStartTime, serviceStatus = currentServiceStatus,
groupsCount = dashboardUiState.groupsCount, startTime = dashboardUiState.serviceStartTime,
hasGroups = dashboardUiState.hasGroups, groupsCount = dashboardUiState.groupsCount,
onGroupsClick = { showGroupsSheet = true }, hasGroups = dashboardUiState.hasGroups,
connectionsCount = dashboardUiState.connectionsOut.toIntOrNull() ?: 0, onGroupsClick = { showGroupsSheet = true },
onConnectionsClick = { showConnectionsSheet = true }, connectionsCount = dashboardUiState.connectionsOut.toIntOrNull() ?: 0,
onStopClick = { dashboardViewModel.toggleService() }, onConnectionsClick = { showConnectionsSheet = true },
modifier = Modifier.align(Alignment.BottomCenter), onStopClick = { dashboardViewModel.toggleService() },
) modifier = Modifier.align(Alignment.BottomCenter),
)
}
// Start FAB (shown when service is stopped and a profile is selected) val showPadFab = useNavigationRail && !isSubScreen && (showStartFab || showStatusBar)
AnimatedVisibility( if (useNavigationRail) {
visible = currentServiceStatus == Status.Stopped && dashboardUiState.selectedProfileId != -1L && !isSettingsSubScreen, androidx.compose.animation.AnimatedVisibility(
enter = scaleIn(), visible = showPadFab,
exit = scaleOut(), enter = scaleIn(),
modifier = Modifier exit = scaleOut(),
.align(Alignment.BottomEnd) modifier = Modifier
.padding(16.dp), .align(Alignment.BottomEnd)
) { .padding(20.dp),
FloatingActionButton(
onClick = { startService() },
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
) { ) {
Icon( val isRunning =
imageVector = Icons.Default.PlayArrow, currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting
contentDescription = stringResource(R.string.action_start), val isStopping = currentServiceStatus == Status.Stopping
) if (currentServiceStatus == Status.Stopped) {
FloatingActionButton(
onClick = { startService() },
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = stringResource(R.string.action_start),
)
}
} else {
ExtendedFloatingActionButton(
onClick = {
if (isRunning || isStopping) {
dashboardViewModel.toggleService()
} else {
startService()
}
},
icon = {
Icon(
imageVector =
if (isRunning || isStopping) {
Icons.Default.Stop
} else {
Icons.Default.PlayArrow
},
contentDescription =
if (isRunning || isStopping) {
stringResource(R.string.stop)
} else {
stringResource(R.string.action_start)
},
)
},
text = {
when {
isRunning && dashboardUiState.serviceStartTime != null -> {
UptimeText(startTime = dashboardUiState.serviceStartTime!!)
}
currentServiceStatus == Status.Started -> {
Text(
text = stringResource(R.string.status_started),
style = MaterialTheme.typography.labelLarge,
)
}
currentServiceStatus == Status.Starting -> {
Text(
text = stringResource(R.string.status_starting),
style = MaterialTheme.typography.labelLarge,
)
}
currentServiceStatus == Status.Stopping -> {
Text(
text = stringResource(R.string.status_stopping),
style = MaterialTheme.typography.labelLarge,
)
}
else -> {
Text(
text = stringResource(R.string.action_start),
style = MaterialTheme.typography.labelLarge,
)
}
}
},
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.height(64.dp),
)
}
}
} else {
// Start FAB (shown when service is stopped and a profile is selected)
androidx.compose.animation.AnimatedVisibility(
visible = currentServiceStatus == Status.Stopped &&
dashboardUiState.selectedProfileId != -1L &&
!isSubScreen,
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),
)
}
} }
} }
} }
} }
if (useNavigationRail) {
Row(modifier = Modifier.fillMaxSize()) {
Surface(tonalElevation = 1.dp) {
NavigationRail(
modifier = Modifier.fillMaxHeight(),
) {
val hasUpdate by UpdateState.hasUpdate
railScreens.forEach { screen ->
val selected = currentRootRoute == screen.route
NavigationRailItem(
icon = {
if (screen == Screen.Settings && hasUpdate) {
BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) {
Icon(screen.icon, contentDescription = null)
}
} else {
Icon(screen.icon, contentDescription = null)
}
},
label = { Text(stringResource(screen.titleRes)) },
selected = selected,
onClick = {
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
)
}
}
}
Scaffold(
modifier = Modifier.weight(1f),
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = topBarContent,
) { paddingValues ->
scaffoldContent(paddingValues)
}
}
} else {
Scaffold(
modifier = Modifier.fillMaxSize(),
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = topBarContent,
bottomBar = {
if (!isSubScreen) {
val hasUpdate by UpdateState.hasUpdate
NavigationBar {
bottomNavigationScreens.forEach { screen ->
NavigationBarItem(
icon = {
if (screen == Screen.Settings && hasUpdate) {
BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) {
Icon(screen.icon, contentDescription = null)
}
} else {
Icon(screen.icon, contentDescription = null)
}
},
selected =
currentDestination?.hierarchy?.any {
it.route == screen.route
} == true,
onClick = {
navController.navigate(screen.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
},
)
}
}
}
},
) { paddingValues ->
scaffoldContent(paddingValues)
}
}
// Groups ModalBottomSheet // Groups ModalBottomSheet
if (showGroupsSheet) { if (showGroupsSheet && !useNavigationRail) {
val groupsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val groupsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val groupsViewModel: GroupsViewModel = viewModel( val groupsViewModel: GroupsViewModel = viewModel(
factory = object : ViewModelProvider.Factory { factory = object : ViewModelProvider.Factory {
@@ -824,7 +1093,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
} }
// Connections ModalBottomSheet // Connections ModalBottomSheet
if (showConnectionsSheet) { if (showConnectionsSheet && !useNavigationRail) {
val connectionsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val connectionsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val connectionsViewModel: ConnectionsViewModel = viewModel( val connectionsViewModel: ConnectionsViewModel = viewModel(
factory = object : ViewModelProvider.Factory { factory = object : ViewModelProvider.Factory {
@@ -837,7 +1106,6 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
val connectionsUiState by connectionsViewModel.uiState.collectAsState() val connectionsUiState by connectionsViewModel.uiState.collectAsState()
var selectedConnectionId by remember { mutableStateOf<String?>(null) } var selectedConnectionId by remember { mutableStateOf<String?>(null) }
val selectedConnection = connectionsUiState.allConnections.find { it.id == selectedConnectionId } val selectedConnection = connectionsUiState.allConnections.find { it.id == selectedConnectionId }
var showConnectionsMenu by remember { mutableStateOf(false) }
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { onDismissRequest = {
@@ -863,174 +1131,11 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
}, },
) )
} else { } else {
var showStateMenu by remember { mutableStateOf(false) } ConnectionsPage(
var showSortMenu by remember { mutableStateOf(false) }
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.title_connections),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.weight(1f))
// State Filter
Box {
FilterChip(
selected = connectionsUiState.stateFilter != ConnectionStateFilter.Active,
onClick = { showStateMenu = true },
label = {
Text(
when (connectionsUiState.stateFilter) {
ConnectionStateFilter.All -> stringResource(R.string.connection_state_all)
ConnectionStateFilter.Active -> stringResource(R.string.connection_state_active)
ConnectionStateFilter.Closed -> stringResource(R.string.connection_state_closed)
}
)
},
leadingIcon = {
Icon(Icons.Default.FilterList, contentDescription = null)
},
)
DropdownMenu(
expanded = showStateMenu,
onDismissRequest = { showStateMenu = false },
) {
ConnectionStateFilter.entries.forEach { filter ->
DropdownMenuItem(
text = {
Text(
when (filter) {
ConnectionStateFilter.All -> stringResource(R.string.connection_state_all)
ConnectionStateFilter.Active -> stringResource(R.string.connection_state_active)
ConnectionStateFilter.Closed -> stringResource(R.string.connection_state_closed)
}
)
},
onClick = {
connectionsViewModel.setStateFilter(filter)
showStateMenu = false
},
leadingIcon = {
if (connectionsUiState.stateFilter == filter) {
Icon(Icons.Default.Check, contentDescription = null)
}
},
)
}
}
}
// Sort
Box {
FilterChip(
selected = connectionsUiState.sort != ConnectionSort.ByDate,
onClick = { showSortMenu = true },
label = {
Text(
when (connectionsUiState.sort) {
ConnectionSort.ByDate -> stringResource(R.string.connection_sort_date)
ConnectionSort.ByTraffic -> stringResource(R.string.connection_sort_traffic)
ConnectionSort.ByTrafficTotal -> stringResource(R.string.connection_sort_traffic_total)
}
)
},
leadingIcon = {
Icon(Icons.Default.SwapVert, contentDescription = null)
},
)
DropdownMenu(
expanded = showSortMenu,
onDismissRequest = { showSortMenu = false },
) {
ConnectionSort.entries.forEach { sort ->
DropdownMenuItem(
text = {
Text(
when (sort) {
ConnectionSort.ByDate -> stringResource(R.string.connection_sort_date)
ConnectionSort.ByTraffic -> stringResource(R.string.connection_sort_traffic)
ConnectionSort.ByTrafficTotal -> stringResource(R.string.connection_sort_traffic_total)
}
)
},
onClick = {
connectionsViewModel.setSort(sort)
showSortMenu = false
},
leadingIcon = {
if (connectionsUiState.sort == sort) {
Icon(Icons.Default.Check, contentDescription = null)
}
},
)
}
}
}
// Menu
Box {
IconButton(onClick = { showConnectionsMenu = true }) {
Icon(Icons.Default.MoreVert, contentDescription = null)
}
DropdownMenu(
expanded = showConnectionsMenu,
onDismissRequest = { showConnectionsMenu = false },
) {
DropdownMenuItem(
text = {
Text(
if (connectionsUiState.isSearchActive) {
stringResource(R.string.close_search)
} else {
stringResource(R.string.search)
}
)
},
onClick = {
connectionsViewModel.toggleSearch()
showConnectionsMenu = false
},
leadingIcon = {
Icon(
imageVector = if (connectionsUiState.isSearchActive) Icons.Default.Close else Icons.Default.Search,
contentDescription = null,
)
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.connection_close_all)) },
onClick = {
connectionsViewModel.closeAllConnections()
showConnectionsMenu = false
},
leadingIcon = {
Icon(Icons.Default.Close, contentDescription = null)
},
enabled = connectionsUiState.connections.any { it.isActive },
)
}
}
}
// Connections content
ConnectionsScreen(
serviceStatus = currentServiceStatus, serviceStatus = currentServiceStatus,
viewModel = connectionsViewModel, viewModel = connectionsViewModel,
onConnectionClick = { selectedConnectionId = it.id }, showTitle = true,
onConnectionClick = { selectedConnectionId = it },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }

View File

@@ -178,7 +178,7 @@ private fun StatusItem(
} }
@Composable @Composable
private fun UptimeText( fun UptimeText(
startTime: Long, startTime: Long,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {

View File

@@ -3,6 +3,7 @@ package io.nekohasekai.sfa.compose.component.qr
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.net.Uri import android.net.Uri
import android.content.res.Configuration
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -16,6 +17,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.layout.sizeIn
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -43,6 +45,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -60,6 +64,8 @@ fun QRSDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val configuration = LocalConfiguration.current
val isTablet = configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE)
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var fps by remember { mutableIntStateOf(QRSConstants.DEFAULT_FPS) } var fps by remember { mutableIntStateOf(QRSConstants.DEFAULT_FPS) }
var sliceSize by remember { mutableIntStateOf(QRSConstants.DEFAULT_SLICE_SIZE) } var sliceSize by remember { mutableIntStateOf(QRSConstants.DEFAULT_SLICE_SIZE) }
@@ -119,20 +125,26 @@ fun QRSDialog(
properties = DialogProperties(usePlatformDefaultWidth = false), properties = DialogProperties(usePlatformDefaultWidth = false),
) { ) {
Card( Card(
modifier = Modifier modifier =
.fillMaxWidth(0.9f) if (isTablet) {
.wrapContentHeight(), Modifier
.fillMaxWidth(0.85f)
.sizeIn(maxWidth = 960.dp)
.wrapContentHeight()
} else {
Modifier
.fillMaxWidth(0.9f)
.wrapContentHeight()
},
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
), ),
) { ) {
Column( val qrSurface: @Composable () -> Unit = {
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Surface( Surface(
modifier = Modifier modifier = Modifier
.sizeIn(maxWidth = if (isTablet) 420.dp else 360.dp, maxHeight = if (isTablet) 420.dp else 360.dp)
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(1f), .aspectRatio(1f),
shape = RoundedCornerShape(0.dp), shape = RoundedCornerShape(0.dp),
@@ -149,17 +161,16 @@ fun QRSDialog(
bitmap = bitmap.asImageBitmap(), bitmap = bitmap.asImageBitmap(),
contentDescription = stringResource(R.string.content_description_qr_code), contentDescription = stringResource(R.string.content_description_qr_code),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
) )
} }
} }
} }
}
Spacer(modifier = Modifier.height(16.dp)) val controlsContent: @Composable () -> Unit = {
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth()
.padding(horizontal = 16.dp),
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -213,10 +224,7 @@ fun QRSDialog(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
OutlinedButton( OutlinedButton(
@@ -249,6 +257,42 @@ fun QRSDialog(
} }
} }
} }
if (isTablet) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalArrangement = Arrangement.spacedBy(24.dp),
verticalAlignment = Alignment.Top,
) {
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
) {
qrSurface()
}
Column(
modifier = Modifier.weight(1f),
) {
controlsContent()
}
}
} else {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
qrSurface()
Spacer(modifier = Modifier.height(16.dp))
controlsContent()
}
}
} }
} }
} }

View File

@@ -4,7 +4,9 @@ import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.TextSnippet import androidx.compose.material.icons.automirrored.filled.TextSnippet
import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SwapVert
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
@@ -25,6 +27,18 @@ sealed class Screen(
icon = Icons.AutoMirrored.Default.TextSnippet, icon = Icons.AutoMirrored.Default.TextSnippet,
) )
object Groups : Screen(
route = "groups",
titleRes = R.string.title_groups,
icon = Icons.Default.Folder,
)
object Connections : Screen(
route = "connections",
titleRes = R.string.title_connections,
icon = Icons.Default.SwapVert,
)
object Settings : Screen( object Settings : Screen(
route = "settings", route = "settings",
titleRes = R.string.title_settings, titleRes = R.string.title_settings,

View File

@@ -1,7 +1,9 @@
package io.nekohasekai.sfa.compose.navigation package io.nekohasekai.sfa.compose.navigation
import android.net.Uri
import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
@@ -9,8 +11,13 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import io.nekohasekai.sfa.compose.screen.dashboard.DashboardScreen import io.nekohasekai.sfa.compose.screen.dashboard.DashboardScreen
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.connections.ConnectionDetailsRoute
import io.nekohasekai.sfa.compose.screen.connections.ConnectionsPage
import io.nekohasekai.sfa.compose.screen.log.LogScreen import io.nekohasekai.sfa.compose.screen.log.LogScreen
import io.nekohasekai.sfa.compose.screen.log.LogViewModel import io.nekohasekai.sfa.compose.screen.log.LogViewModel
import io.nekohasekai.sfa.compose.screen.connections.ConnectionsViewModel
import io.nekohasekai.sfa.compose.screen.settings.AppSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.AppSettingsScreen
import io.nekohasekai.sfa.compose.screen.settings.CoreSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.CoreSettingsScreen
import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen
@@ -26,6 +33,8 @@ fun SFANavHost(
showStatusBar: Boolean = false, showStatusBar: Boolean = false,
dashboardViewModel: DashboardViewModel? = null, dashboardViewModel: DashboardViewModel? = null,
logViewModel: LogViewModel? = null, logViewModel: LogViewModel? = null,
groupsViewModel: GroupsViewModel? = null,
connectionsViewModel: ConnectionsViewModel? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
NavHost( NavHost(
@@ -67,6 +76,66 @@ fun SFANavHost(
} }
} }
composable(Screen.Groups.route) {
if (groupsViewModel != null) {
GroupsCard(
serviceStatus = serviceStatus,
viewModel = groupsViewModel,
modifier = Modifier.fillMaxSize(),
)
} else {
GroupsCard(
serviceStatus = serviceStatus,
modifier = Modifier.fillMaxSize(),
)
}
}
composable(Screen.Connections.route) {
if (connectionsViewModel != null) {
ConnectionsPage(
serviceStatus = serviceStatus,
viewModel = connectionsViewModel,
showTitle = false,
onConnectionClick = { connectionId ->
navController.navigate("connections/detail/${Uri.encode(connectionId)}")
},
modifier = Modifier.fillMaxSize(),
)
} else {
ConnectionsPage(
serviceStatus = serviceStatus,
showTitle = false,
onConnectionClick = { connectionId ->
navController.navigate("connections/detail/${Uri.encode(connectionId)}")
},
modifier = Modifier.fillMaxSize(),
)
}
}
composable("connections/detail/{connectionId}") { backStackEntry ->
val connectionId = backStackEntry.arguments?.getString("connectionId")
if (connectionId != null) {
if (connectionsViewModel != null) {
ConnectionDetailsRoute(
connectionId = connectionId,
serviceStatus = serviceStatus,
viewModel = connectionsViewModel,
onBack = { navController.navigateUp() },
modifier = Modifier.fillMaxSize(),
)
} else {
ConnectionDetailsRoute(
connectionId = connectionId,
serviceStatus = serviceStatus,
onBack = { navController.navigateUp() },
modifier = Modifier.fillMaxSize(),
)
}
}
}
composable(Screen.Settings.route) { composable(Screen.Settings.route) {
SettingsScreen(navController = navController) SettingsScreen(navController = navController)
} }

View File

@@ -54,6 +54,7 @@ fun ConnectionDetailsScreen(
onBack: () -> Unit, onBack: () -> Unit,
onClose: () -> Unit, onClose: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showHeader: Boolean = true,
) { ) {
val dateTimeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) val dateTimeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
@@ -66,43 +67,45 @@ fun ConnectionDetailsScreen(
.nestedScroll(bounceBlockingConnection) .nestedScroll(bounceBlockingConnection)
.verticalScroll(scrollState), .verticalScroll(scrollState),
) { ) {
Row( if (showHeader) {
modifier = Modifier Row(
.fillMaxWidth() modifier = Modifier
.padding(horizontal = 24.dp) .fillMaxWidth()
.padding(bottom = 16.dp), .padding(horizontal = 24.dp)
horizontalArrangement = Arrangement.SpaceBetween, .padding(bottom = 16.dp),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween,
) { verticalAlignment = Alignment.CenterVertically,
IconButton(onClick = onBack) { ) {
Icon( IconButton(onClick = onBack) {
Icons.AutoMirrored.Filled.ArrowBack, Icon(
contentDescription = stringResource(R.string.content_description_back), Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.content_description_back),
)
}
Text(
text = stringResource(R.string.connection_details),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
) )
} if (connection.isActive) {
Text( Box {
text = stringResource(R.string.connection_details), IconButton(onClick = { showMenu = true }) {
style = MaterialTheme.typography.headlineSmall, Icon(Icons.Default.MoreVert, contentDescription = null)
fontWeight = FontWeight.Medium, }
color = MaterialTheme.colorScheme.onSurface, DropdownMenu(
modifier = Modifier.weight(1f), expanded = showMenu,
) onDismissRequest = { showMenu = false },
if (connection.isActive) { ) {
Box { DropdownMenuItem(
IconButton(onClick = { showMenu = true }) { text = { Text(stringResource(R.string.connection_close)) },
Icon(Icons.Default.MoreVert, contentDescription = null) onClick = {
} onClose()
DropdownMenu( showMenu = false
expanded = showMenu, },
onDismissRequest = { showMenu = false }, )
) { }
DropdownMenuItem(
text = { Text(stringResource(R.string.connection_close)) },
onClick = {
onClose()
showMenu = false
},
)
} }
} }
} }

View File

@@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -22,9 +24,16 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.Velocity
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SwapVert
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilterChip
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
@@ -34,7 +43,9 @@ 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
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@@ -43,9 +54,238 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import io.nekohasekai.sfa.R import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.compose.model.ConnectionSort
import io.nekohasekai.sfa.compose.model.ConnectionStateFilter
import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.compose.model.Connection import io.nekohasekai.sfa.compose.model.Connection
@Composable
fun ConnectionsPage(
serviceStatus: Status,
viewModel: ConnectionsViewModel = viewModel(),
showTitle: Boolean = true,
onConnectionClick: (String) -> Unit = {},
modifier: Modifier = Modifier,
) {
val uiState by viewModel.uiState.collectAsState()
var showStateMenu by remember { mutableStateOf(false) }
var showSortMenu by remember { mutableStateOf(false) }
var showConnectionsMenu by remember { mutableStateOf(false) }
Column(
modifier = modifier.fillMaxSize(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (showTitle) {
Text(
text = stringResource(R.string.title_connections),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
Spacer(modifier = Modifier.weight(1f))
Box {
FilterChip(
selected = uiState.stateFilter != ConnectionStateFilter.Active,
onClick = { showStateMenu = true },
label = {
Text(
when (uiState.stateFilter) {
ConnectionStateFilter.All -> stringResource(R.string.connection_state_all)
ConnectionStateFilter.Active -> stringResource(R.string.connection_state_active)
ConnectionStateFilter.Closed -> stringResource(R.string.connection_state_closed)
}
)
},
)
DropdownMenu(
expanded = showStateMenu,
onDismissRequest = { showStateMenu = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.connection_state_all)) },
onClick = {
viewModel.setStateFilter(ConnectionStateFilter.All)
showStateMenu = false
},
leadingIcon = {
if (uiState.stateFilter == ConnectionStateFilter.All) {
Icon(Icons.Default.Check, contentDescription = null)
}
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.connection_state_active)) },
onClick = {
viewModel.setStateFilter(ConnectionStateFilter.Active)
showStateMenu = false
},
leadingIcon = {
if (uiState.stateFilter == ConnectionStateFilter.Active) {
Icon(Icons.Default.Check, contentDescription = null)
}
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.connection_state_closed)) },
onClick = {
viewModel.setStateFilter(ConnectionStateFilter.Closed)
showStateMenu = false
},
leadingIcon = {
if (uiState.stateFilter == ConnectionStateFilter.Closed) {
Icon(Icons.Default.Check, contentDescription = null)
}
},
)
}
}
Box {
IconButton(onClick = { showSortMenu = true }) {
Icon(Icons.Default.SwapVert, contentDescription = null)
}
DropdownMenu(
expanded = showSortMenu,
onDismissRequest = { showSortMenu = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.connection_sort_date)) },
onClick = {
viewModel.setSort(ConnectionSort.ByDate)
showSortMenu = false
},
leadingIcon = {
if (uiState.sort == ConnectionSort.ByDate) {
Icon(Icons.Default.Check, contentDescription = null)
}
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.connection_sort_traffic)) },
onClick = {
viewModel.setSort(ConnectionSort.ByTraffic)
showSortMenu = false
},
leadingIcon = {
if (uiState.sort == ConnectionSort.ByTraffic) {
Icon(Icons.Default.Check, contentDescription = null)
}
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.connection_sort_traffic_total)) },
onClick = {
viewModel.setSort(ConnectionSort.ByTrafficTotal)
showSortMenu = false
},
leadingIcon = {
if (uiState.sort == ConnectionSort.ByTrafficTotal) {
Icon(Icons.Default.Check, contentDescription = null)
}
},
)
}
}
Box {
IconButton(onClick = { showConnectionsMenu = true }) {
Icon(Icons.Default.MoreVert, contentDescription = null)
}
DropdownMenu(
expanded = showConnectionsMenu,
onDismissRequest = { showConnectionsMenu = false },
) {
DropdownMenuItem(
text = {
Text(
if (uiState.isSearchActive) {
stringResource(R.string.close_search)
} else {
stringResource(R.string.search)
}
)
},
onClick = {
viewModel.toggleSearch()
showConnectionsMenu = false
},
leadingIcon = {
Icon(
imageVector = if (uiState.isSearchActive) Icons.Default.Close else Icons.Default.Search,
contentDescription = null,
)
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.connection_close_all)) },
onClick = {
viewModel.closeAllConnections()
showConnectionsMenu = false
},
leadingIcon = {
Icon(Icons.Default.Close, contentDescription = null)
},
enabled = uiState.connections.any { it.isActive },
)
}
}
}
ConnectionsScreen(
serviceStatus = serviceStatus,
viewModel = viewModel,
onConnectionClick = { connection -> onConnectionClick(connection.id) },
modifier = Modifier.fillMaxSize(),
)
}
}
@Composable
fun ConnectionDetailsRoute(
connectionId: String,
serviceStatus: Status,
viewModel: ConnectionsViewModel = viewModel(),
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val uiState by viewModel.uiState.collectAsState()
val connection =
uiState.allConnections.find { it.id == connectionId }
?: uiState.connections.find { it.id == connectionId }
LaunchedEffect(serviceStatus) {
viewModel.updateServiceStatus(serviceStatus)
}
if (connection == null) {
LaunchedEffect(connectionId) {
onBack()
}
Box(modifier = modifier.fillMaxSize())
} else {
ConnectionDetailsScreen(
connection = connection,
onBack = onBack,
onClose = { viewModel.closeConnection(connectionId) },
modifier = modifier,
showHeader = false,
)
}
}
@Composable @Composable
fun ConnectionsScreen( fun ConnectionsScreen(
serviceStatus: Status, serviceStatus: Status,

View File

@@ -2,6 +2,7 @@ package io.nekohasekai.sfa.compose.screen.log
import android.content.ClipData import android.content.ClipData
import android.os.Build import android.os.Build
import android.content.res.Configuration
import android.content.Intent import android.content.Intent
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -75,6 +76,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -102,6 +104,8 @@ fun LogScreen(
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val configuration = LocalConfiguration.current
val isTablet = configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE)
val listState = rememberLazyListState() val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@@ -719,16 +723,19 @@ fun LogScreen(
} }
// FABs - Hide during selection mode // FABs - Hide during selection mode
val padFabVisible = isTablet && (showStartFab || showStatusBar)
val fabBottomPadding = when { val fabBottomPadding = when {
padFabVisible -> 20.dp + 64.dp + 16.dp
showStartFab -> 88.dp showStartFab -> 88.dp
showStatusBar -> 74.dp showStatusBar -> 74.dp
else -> 16.dp else -> 16.dp
} }
val fabEndPadding = if (isTablet) 20.dp else 16.dp
Column( Column(
modifier = modifier =
Modifier Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(bottom = fabBottomPadding, end = 16.dp, top = 16.dp), .padding(bottom = fabBottomPadding, end = fabEndPadding, top = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
// Scroll to bottom FAB // Scroll to bottom FAB

View File

@@ -279,12 +279,71 @@ fun AppSettingsScreen(navController: NavController) {
}, },
modifier = modifier =
Modifier Modifier
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)), .clip(RoundedCornerShape(12.dp)),
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.update_settings),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
Card(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column {
val updateItemCount =
run {
var count = 0
if (Vendor.supportsTrackSelection()) {
count += 1
}
count += 1
if (Vendor.supportsSilentInstall()) {
count += 1
if (silentInstallEnabled) {
count += 1
if (silentInstallMethod == "SHIZUKU" && !isMethodAvailable) {
count += 1
}
if (silentInstallMethod == "PACKAGE_INSTALLER" && !isMethodAvailable) {
count += 1
}
}
}
if (Vendor.supportsAutoUpdate()) {
count += 1
}
count
}
var updateItemIndex = 0
fun updateItemModifier(): Modifier {
val index = updateItemIndex++
return when {
updateItemCount == 1 -> Modifier.clip(RoundedCornerShape(12.dp))
index == 0 -> Modifier.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
index == updateItemCount - 1 ->
Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
else -> Modifier
}
}
if (Vendor.supportsTrackSelection()) { if (Vendor.supportsTrackSelection()) {
ListItem( ListItem(
@@ -309,7 +368,7 @@ fun AppSettingsScreen(navController: NavController) {
) )
}, },
modifier = modifier =
Modifier updateItemModifier()
.clickable { showTrackDialog = true }, .clickable { showTrackDialog = true },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
@@ -343,40 +402,14 @@ fun AppSettingsScreen(navController: NavController) {
}, },
) )
}, },
modifier = modifier = updateItemModifier(),
Modifier
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)),
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
}
}
// Silent Install Section (Other flavor only) if (Vendor.supportsSilentInstall()) {
if (Vendor.supportsSilentInstall()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.silent_install_title),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
)
Card(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column {
// Silent Install toggle
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
@@ -433,16 +466,13 @@ fun AppSettingsScreen(navController: NavController) {
) )
} }
}, },
modifier = modifier = updateItemModifier(),
Modifier
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)),
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
) )
// Install Method row (when enabled)
if (silentInstallEnabled) { if (silentInstallEnabled) {
ListItem( ListItem(
headlineContent = { headlineContent = {
@@ -470,7 +500,7 @@ fun AppSettingsScreen(navController: NavController) {
) )
}, },
modifier = modifier =
Modifier updateItemModifier()
.clickable { showInstallMethodMenu = true }, .clickable { showInstallMethodMenu = true },
colors = colors =
ListItemDefaults.colors( ListItemDefaults.colors(
@@ -478,7 +508,6 @@ fun AppSettingsScreen(navController: NavController) {
), ),
) )
// Get Shizuku row (when Shizuku is selected but not available)
if (silentInstallMethod == "SHIZUKU" && !isMethodAvailable) { if (silentInstallMethod == "SHIZUKU" && !isMethodAvailable) {
ListItem( ListItem(
headlineContent = { headlineContent = {
@@ -502,7 +531,7 @@ fun AppSettingsScreen(navController: NavController) {
) )
}, },
modifier = modifier =
Modifier updateItemModifier()
.clickable { .clickable {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://shizuku.rikka.app/")) val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://shizuku.rikka.app/"))
context.startActivity(intent) context.startActivity(intent)
@@ -514,7 +543,6 @@ fun AppSettingsScreen(navController: NavController) {
) )
} }
// Grant Install Permission row (when PackageInstaller is selected but permission not granted)
if (silentInstallMethod == "PACKAGE_INSTALLER" && !isMethodAvailable) { if (silentInstallMethod == "PACKAGE_INSTALLER" && !isMethodAvailable) {
ListItem( ListItem(
headlineContent = { headlineContent = {
@@ -538,7 +566,7 @@ fun AppSettingsScreen(navController: NavController) {
) )
}, },
modifier = modifier =
Modifier updateItemModifier()
.clickable { .clickable {
val intent = Intent( val intent = Intent(
AndroidSettings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, AndroidSettings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
@@ -553,51 +581,48 @@ fun AppSettingsScreen(navController: NavController) {
) )
} }
} }
}
// Auto Update toggle if (Vendor.supportsAutoUpdate()) {
if (Vendor.supportsAutoUpdate()) { ListItem(
ListItem( headlineContent = {
headlineContent = { Text(
Text( stringResource(R.string.auto_update),
stringResource(R.string.auto_update), style = MaterialTheme.typography.bodyLarge,
style = MaterialTheme.typography.bodyLarge, )
) },
}, supportingContent = {
supportingContent = { Text(
Text( stringResource(R.string.auto_update_description),
stringResource(R.string.auto_update_description), style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.onSurfaceVariant, )
) },
}, leadingContent = {
leadingContent = { Icon(
Icon( imageVector = Icons.Outlined.SystemUpdateAlt,
imageVector = Icons.Outlined.SystemUpdateAlt, contentDescription = null,
contentDescription = null, tint = MaterialTheme.colorScheme.primary,
tint = MaterialTheme.colorScheme.primary, )
) },
}, trailingContent = {
trailingContent = { Switch(
Switch( checked = autoUpdateEnabled,
checked = autoUpdateEnabled, onCheckedChange = { checked ->
onCheckedChange = { checked -> autoUpdateEnabled = checked
autoUpdateEnabled = checked scope.launch(Dispatchers.IO) {
scope.launch(Dispatchers.IO) { Settings.autoUpdateEnabled = checked
Settings.autoUpdateEnabled = checked Vendor.scheduleAutoUpdate()
Vendor.scheduleAutoUpdate() }
} },
}, )
) },
}, modifier = updateItemModifier(),
modifier = colors =
Modifier ListItemDefaults.colors(
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)), containerColor = Color.Transparent,
colors = ),
ListItemDefaults.colors( )
containerColor = Color.Transparent,
),
)
}
} }
} }
} }

View File

@@ -203,6 +203,7 @@
<string name="auto_redirect">自动重定向</string> <string name="auto_redirect">自动重定向</string>
<string name="auto_redirect_description">需要 ROOT 权限</string> <string name="auto_redirect_description">需要 ROOT 权限</string>
<string name="system_http_proxy">系统 HTTP 代理</string> <string name="system_http_proxy">系统 HTTP 代理</string>
<string name="update_settings">更新设置</string>
<!-- Per-App Proxy --> <!-- Per-App Proxy -->
<string name="per_app_proxy">分应用代理</string> <string name="per_app_proxy">分应用代理</string>

View File

@@ -205,6 +205,7 @@
<string name="auto_redirect">Auto Redirect</string> <string name="auto_redirect">Auto Redirect</string>
<string name="auto_redirect_description">ROOT permission required</string> <string name="auto_redirect_description">ROOT permission required</string>
<string name="system_http_proxy">System HTTP Proxy</string> <string name="system_http_proxy">System HTTP Proxy</string>
<string name="update_settings">Update Settings</string>
<!-- Per-App Proxy --> <!-- Per-App Proxy -->
<string name="per_app_proxy">Per-App Proxy</string> <string name="per_app_proxy">Per-App Proxy</string>