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.annotation.SuppressLint
import android.content.Intent
import android.content.res.Configuration
import android.net.VpnService
import android.os.Build
import android.os.Bundle
@@ -15,6 +16,7 @@ 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.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
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.material.icons.Icons
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.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.Pause
import androidx.compose.material.icons.filled.PlayArrow
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.UnfoldMore
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilterChip
import dev.jeziellago.compose.markdowntext.MarkdownText
import androidx.compose.material3.Badge
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Job
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -58,8 +56,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SheetState
import androidx.compose.material3.Surface
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
@@ -76,6 +76,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.core.content.ContextCompat
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.UiEvent
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.navigation.SFANavHost
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.GroupsCard
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.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.log.LogViewModel
import io.nekohasekai.sfa.compose.theme.SFATheme
@@ -268,8 +267,13 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
val currentRoute = currentDestination?.route
val scope = rememberCoroutineScope()
val configuration = LocalConfiguration.current
val useNavigationRail =
configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE)
// Snackbar state
val snackbarHostState = remember { SnackbarHostState() }
@@ -490,16 +494,32 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
}
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
val currentScreen =
bottomNavigationScreens.find { screen ->
currentDestination?.route == screen.route
} ?: bottomNavigationScreens[0]
when (currentRootRoute) {
Screen.Dashboard.route -> Screen.Dashboard
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 isSettingsSubScreen = currentDestination?.route?.startsWith("settings/") == true
val isSubScreen = isSettingsSubScreen || isConnectionsDetail
val settingsScreenTitle =
when (currentDestination?.route) {
when (currentRoute) {
"settings/app" -> stringResource(R.string.title_app_settings)
"settings/core" -> stringResource(R.string.core)
"settings/service" -> stringResource(R.string.service)
@@ -515,6 +535,76 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
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
LaunchedEffect(Unit) {
GlobalEventBus.events.collect { event ->
@@ -565,143 +655,137 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
if (isSettingsSubScreen && settingsScreenTitle != null) {
settingsScreenTitle
} else {
stringResource(currentScreen.titleRes)
},
)
},
navigationIcon = {
if (isSettingsSubScreen) {
IconButton(onClick = { navController.navigateUp() }) {
val topBarContent: @Composable () -> Unit = {
TopAppBar(
title = {
Text(
when {
isSettingsSubScreen && settingsScreenTitle != null -> settingsScreenTitle
isConnectionsDetail -> stringResource(R.string.connection_details)
else -> stringResource(currentScreen.titleRes)
},
)
},
navigationIcon = {
if (isSubScreen) {
IconButton(onClick = { navController.navigateUp() }) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
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(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = stringResource(R.string.content_description_back),
imageVector = if (allCollapsed) Icons.Default.UnfoldMore else Icons.Default.UnfoldLess,
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 (currentScreen == Screen.Dashboard && !isSettingsSubScreen) {
// More options button
IconButton(onClick = { dashboardViewModel.toggleCardSettingsDialog() }) {
}
if (isConnectionsDetail && connectionsViewModel != null) {
val connectionsUiState by connectionsViewModel.uiState.collectAsState()
val connectionId = navBackStackEntry?.arguments?.getString("connectionId")
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(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.title_others),
contentDescription = stringResource(R.string.more_options),
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
}
},
)
}
}
}
},
) { paddingValues ->
},
colors = TopAppBarDefaults.topAppBarColors(),
)
}
val scaffoldContent: @Composable (PaddingValues) -> Unit = { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
@@ -720,46 +804,231 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
showStatusBar = showStatusBar,
dashboardViewModel = dashboardViewModel,
logViewModel = logViewModel,
groupsViewModel = groupsViewModel,
connectionsViewModel = connectionsViewModel,
modifier = Modifier.fillMaxSize(),
)
ServiceStatusBar(
visible = showStatusBar && !isSettingsSubScreen,
serviceStatus = currentServiceStatus,
startTime = dashboardUiState.serviceStartTime,
groupsCount = dashboardUiState.groupsCount,
hasGroups = dashboardUiState.hasGroups,
onGroupsClick = { showGroupsSheet = true },
connectionsCount = dashboardUiState.connectionsOut.toIntOrNull() ?: 0,
onConnectionsClick = { showConnectionsSheet = true },
onStopClick = { dashboardViewModel.toggleService() },
modifier = Modifier.align(Alignment.BottomCenter),
)
if (!useNavigationRail) {
ServiceStatusBar(
visible = showStatusBar && !isSubScreen,
serviceStatus = currentServiceStatus,
startTime = dashboardUiState.serviceStartTime,
groupsCount = dashboardUiState.groupsCount,
hasGroups = dashboardUiState.hasGroups,
onGroupsClick = { showGroupsSheet = true },
connectionsCount = dashboardUiState.connectionsOut.toIntOrNull() ?: 0,
onConnectionsClick = { showConnectionsSheet = true },
onStopClick = { dashboardViewModel.toggleService() },
modifier = Modifier.align(Alignment.BottomCenter),
)
}
// Start FAB (shown when service is stopped and a profile is selected)
AnimatedVisibility(
visible = currentServiceStatus == Status.Stopped && 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,
val showPadFab = useNavigationRail && !isSubScreen && (showStartFab || showStatusBar)
if (useNavigationRail) {
androidx.compose.animation.AnimatedVisibility(
visible = showPadFab,
enter = scaleIn(),
exit = scaleOut(),
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(20.dp),
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = stringResource(R.string.action_start),
)
val isRunning =
currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting
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
if (showGroupsSheet) {
if (showGroupsSheet && !useNavigationRail) {
val groupsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val groupsViewModel: GroupsViewModel = viewModel(
factory = object : ViewModelProvider.Factory {
@@ -824,7 +1093,7 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
}
// Connections ModalBottomSheet
if (showConnectionsSheet) {
if (showConnectionsSheet && !useNavigationRail) {
val connectionsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val connectionsViewModel: ConnectionsViewModel = viewModel(
factory = object : ViewModelProvider.Factory {
@@ -837,7 +1106,6 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
val connectionsUiState by connectionsViewModel.uiState.collectAsState()
var selectedConnectionId by remember { mutableStateOf<String?>(null) }
val selectedConnection = connectionsUiState.allConnections.find { it.id == selectedConnectionId }
var showConnectionsMenu by remember { mutableStateOf(false) }
ModalBottomSheet(
onDismissRequest = {
@@ -863,174 +1131,11 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
},
)
} else {
var showStateMenu by remember { mutableStateOf(false) }
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(
ConnectionsPage(
serviceStatus = currentServiceStatus,
viewModel = connectionsViewModel,
onConnectionClick = { selectedConnectionId = it.id },
showTitle = true,
onConnectionClick = { selectedConnectionId = it },
modifier = Modifier.fillMaxSize(),
)
}

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
package io.nekohasekai.sfa.compose.navigation
import android.net.Uri
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
@@ -9,8 +11,13 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import io.nekohasekai.sfa.compose.screen.dashboard.DashboardScreen
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.LogViewModel
import io.nekohasekai.sfa.compose.screen.connections.ConnectionsViewModel
import io.nekohasekai.sfa.compose.screen.settings.AppSettingsScreen
import io.nekohasekai.sfa.compose.screen.settings.CoreSettingsScreen
import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen
@@ -26,6 +33,8 @@ fun SFANavHost(
showStatusBar: Boolean = false,
dashboardViewModel: DashboardViewModel? = null,
logViewModel: LogViewModel? = null,
groupsViewModel: GroupsViewModel? = null,
connectionsViewModel: ConnectionsViewModel? = null,
modifier: Modifier = Modifier,
) {
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) {
SettingsScreen(navController = navController)
}

View File

@@ -54,6 +54,7 @@ fun ConnectionDetailsScreen(
onBack: () -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
showHeader: Boolean = true,
) {
val dateTimeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
var showMenu by remember { mutableStateOf(false) }
@@ -66,43 +67,45 @@ fun ConnectionDetailsScreen(
.nestedScroll(bounceBlockingConnection)
.verticalScroll(scrollState),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.content_description_back),
if (showHeader) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(
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),
)
}
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) {
Box {
IconButton(onClick = { showMenu = true }) {
Icon(Icons.Default.MoreVert, contentDescription = null)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.connection_close)) },
onClick = {
onClose()
showMenu = false
},
)
if (connection.isActive) {
Box {
IconButton(onClick = { showMenu = true }) {
Icon(Icons.Default.MoreVert, contentDescription = null)
}
DropdownMenu(
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.Column
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.fillMaxWidth
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.unit.Velocity
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.Close
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SwapVert
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.IconButton
import androidx.compose.material3.MaterialTheme
@@ -34,7 +43,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@@ -43,9 +54,238 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
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.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
fun ConnectionsScreen(
serviceStatus: Status,

View File

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

View File

@@ -279,12 +279,71 @@ fun AppSettingsScreen(navController: NavController) {
},
modifier =
Modifier
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)),
.clip(RoundedCornerShape(12.dp)),
colors =
ListItemDefaults.colors(
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()) {
ListItem(
@@ -309,7 +368,7 @@ fun AppSettingsScreen(navController: NavController) {
)
},
modifier =
Modifier
updateItemModifier()
.clickable { showTrackDialog = true },
colors =
ListItemDefaults.colors(
@@ -343,40 +402,14 @@ fun AppSettingsScreen(navController: NavController) {
},
)
},
modifier =
Modifier
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)),
modifier = updateItemModifier(),
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
}
}
// Silent Install Section (Other flavor only)
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
if (Vendor.supportsSilentInstall()) {
ListItem(
headlineContent = {
Text(
@@ -433,16 +466,13 @@ fun AppSettingsScreen(navController: NavController) {
)
}
},
modifier =
Modifier
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)),
modifier = updateItemModifier(),
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
// Install Method row (when enabled)
if (silentInstallEnabled) {
ListItem(
headlineContent = {
@@ -470,7 +500,7 @@ fun AppSettingsScreen(navController: NavController) {
)
},
modifier =
Modifier
updateItemModifier()
.clickable { showInstallMethodMenu = true },
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) {
ListItem(
headlineContent = {
@@ -502,7 +531,7 @@ fun AppSettingsScreen(navController: NavController) {
)
},
modifier =
Modifier
updateItemModifier()
.clickable {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://shizuku.rikka.app/"))
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) {
ListItem(
headlineContent = {
@@ -538,7 +566,7 @@ fun AppSettingsScreen(navController: NavController) {
)
},
modifier =
Modifier
updateItemModifier()
.clickable {
val intent = Intent(
AndroidSettings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
@@ -553,51 +581,48 @@ fun AppSettingsScreen(navController: NavController) {
)
}
}
}
// Auto Update toggle
if (Vendor.supportsAutoUpdate()) {
ListItem(
headlineContent = {
Text(
stringResource(R.string.auto_update),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(
stringResource(R.string.auto_update_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.SystemUpdateAlt,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
trailingContent = {
Switch(
checked = autoUpdateEnabled,
onCheckedChange = { checked ->
autoUpdateEnabled = checked
scope.launch(Dispatchers.IO) {
Settings.autoUpdateEnabled = checked
Vendor.scheduleAutoUpdate()
}
},
)
},
modifier =
Modifier
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)),
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
}
if (Vendor.supportsAutoUpdate()) {
ListItem(
headlineContent = {
Text(
stringResource(R.string.auto_update),
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(
stringResource(R.string.auto_update_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
leadingContent = {
Icon(
imageVector = Icons.Outlined.SystemUpdateAlt,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
trailingContent = {
Switch(
checked = autoUpdateEnabled,
onCheckedChange = { checked ->
autoUpdateEnabled = checked
scope.launch(Dispatchers.IO) {
Settings.autoUpdateEnabled = checked
Vendor.scheduleAutoUpdate()
}
},
)
},
modifier = updateItemModifier(),
colors =
ListItemDefaults.colors(
containerColor = Color.Transparent,
),
)
}
}
}

View File

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

View File

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