Refine tablet UI and QRS layout
This commit is contained in:
@@ -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,22 +655,19 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||
topBar = {
|
||||
val topBarContent: @Composable () -> Unit = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
if (isSettingsSubScreen && settingsScreenTitle != null) {
|
||||
settingsScreenTitle
|
||||
} else {
|
||||
stringResource(currentScreen.titleRes)
|
||||
when {
|
||||
isSettingsSubScreen && settingsScreenTitle != null -> settingsScreenTitle
|
||||
isConnectionsDetail -> stringResource(R.string.connection_details)
|
||||
else -> stringResource(currentScreen.titleRes)
|
||||
},
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
if (isSettingsSubScreen) {
|
||||
if (isSubScreen) {
|
||||
IconButton(onClick = { navController.navigateUp() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Default.ArrowBack,
|
||||
@@ -602,6 +689,40 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
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 = if (allCollapsed) Icons.Default.UnfoldMore else Icons.Default.UnfoldLess,
|
||||
contentDescription =
|
||||
if (allCollapsed) {
|
||||
stringResource(R.string.expand_all)
|
||||
} else {
|
||||
stringResource(R.string.collapse_all)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -662,10 +783,210 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(),
|
||||
)
|
||||
}
|
||||
|
||||
val scaffoldContent: @Composable (PaddingValues) -> Unit = { paddingValues ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
) {
|
||||
// Service Status Bar (shown when service is running or stopping)
|
||||
val serviceRunning =
|
||||
currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting
|
||||
val showStatusBar = serviceRunning || currentServiceStatus == Status.Stopping
|
||||
val showStartFab = !serviceRunning && dashboardUiState.selectedProfileId != -1L
|
||||
|
||||
SFANavHost(
|
||||
navController = navController,
|
||||
serviceStatus = currentServiceStatus,
|
||||
showStartFab = showStartFab,
|
||||
showStatusBar = showStatusBar,
|
||||
dashboardViewModel = dashboardViewModel,
|
||||
logViewModel = logViewModel,
|
||||
groupsViewModel = groupsViewModel,
|
||||
connectionsViewModel = connectionsViewModel,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
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),
|
||||
) {
|
||||
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 = {
|
||||
// Only show bottom bar when not in settings sub-screens
|
||||
if (!isSettingsSubScreen) {
|
||||
if (!isSubScreen) {
|
||||
val hasUpdate by UpdateState.hasUpdate
|
||||
NavigationBar {
|
||||
bottomNavigationScreens.forEach { screen ->
|
||||
@@ -702,64 +1023,12 @@ class MainActivity : ComponentActivity(), ServiceConnection.Callback {
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
) {
|
||||
// Service Status Bar (shown when service is running or stopping)
|
||||
val serviceRunning =
|
||||
currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting
|
||||
val showStatusBar = serviceRunning || currentServiceStatus == Status.Stopping
|
||||
val showStartFab = !serviceRunning && dashboardUiState.selectedProfileId != -1L
|
||||
|
||||
SFANavHost(
|
||||
navController = navController,
|
||||
serviceStatus = currentServiceStatus,
|
||||
showStartFab = showStartFab,
|
||||
showStatusBar = showStatusBar,
|
||||
dashboardViewModel = dashboardViewModel,
|
||||
logViewModel = logViewModel,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
ServiceStatusBar(
|
||||
visible = showStatusBar && !isSettingsSubScreen,
|
||||
serviceStatus = currentServiceStatus,
|
||||
startTime = dashboardUiState.serviceStartTime,
|
||||
groupsCount = dashboardUiState.groupsCount,
|
||||
hasGroups = dashboardUiState.hasGroups,
|
||||
onGroupsClick = { showGroupsSheet = true },
|
||||
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,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PlayArrow,
|
||||
contentDescription = stringResource(R.string.action_start),
|
||||
)
|
||||
}
|
||||
}
|
||||
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(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ private fun StatusItem(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UptimeText(
|
||||
fun UptimeText(
|
||||
startTime: Long,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
modifier =
|
||||
if (isTablet) {
|
||||
Modifier
|
||||
.fillMaxWidth(0.85f)
|
||||
.sizeIn(maxWidth = 960.dp)
|
||||
.wrapContentHeight()
|
||||
} else {
|
||||
Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
.wrapContentHeight(),
|
||||
.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,6 +67,7 @@ fun ConnectionDetailsScreen(
|
||||
.nestedScroll(bounceBlockingConnection)
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
if (showHeader) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -107,6 +109,7 @@ fun ConnectionDetailsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DetailSection(title = stringResource(R.string.connection_section_basic)) {
|
||||
DetailRow(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
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,8 +581,8 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto Update toggle
|
||||
if (Vendor.supportsAutoUpdate()) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
@@ -589,9 +617,7 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)),
|
||||
modifier = updateItemModifier(),
|
||||
colors =
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
@@ -600,7 +626,6 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user