diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt index 7c155b3..45b6766 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt @@ -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 create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return GroupsViewModel(dashboardViewModel.commandClient) as T + } + } + ) + } else { + null + } + + val connectionsViewModel: ConnectionsViewModel? = + if (isConnectionsRoute) { + viewModel( + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): 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(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(), ) } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/component/ServiceStatusBar.kt b/app/src/main/java/io/nekohasekai/sfa/compose/component/ServiceStatusBar.kt index d79621a..1c65745 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/component/ServiceStatusBar.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/component/ServiceStatusBar.kt @@ -178,7 +178,7 @@ private fun StatusItem( } @Composable -private fun UptimeText( +fun UptimeText( startTime: Long, modifier: Modifier = Modifier, ) { diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSDialog.kt b/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSDialog.kt index c67ce59..e580bda 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSDialog.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSDialog.kt @@ -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() + } + } } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt index 7f93697..0dc6437 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt @@ -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, diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt index 8df8f4c..7e9458f 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt @@ -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) } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt index 5b3448a..eb997e4 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt @@ -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 + }, + ) + } } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt index 2d8e724..85b84c3 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt @@ -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, diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt index e0698c1..89f0d28 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt @@ -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 diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt index 2579385..a7b35f3 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt @@ -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, + ), + ) } } } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 4c203ac..0224479 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -203,6 +203,7 @@ 自动重定向 需要 ROOT 权限 系统 HTTP 代理 + 更新设置 分应用代理 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5e30655..364e583 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -205,6 +205,7 @@ Auto Redirect ROOT permission required System HTTP Proxy + Update Settings Per-App Proxy