Theme picker, drawer back button, mini numpad 15%, session drawer mode
Theme picker: - EditConnection now shows "Default (<global theme>)" using the actual global theme from TerminalPreferences, not the hardcoded "Default Dark" - Dropdown adds a Default entry at the top that clears the per-connection override (empty string) - SessionTabBar theme picker replaced hardcoded 7-theme list with TerminalTheme.builtInThemes (all 20 themes), scrollable column, and a Default entry that clears the per-session override - MainViewModel exposes globalThemeName StateFlow; threaded through SessionTabBar and SessionDrawerContent - Fix setSessionTheme: blank name clears the override (was wrongly treating the literal "Default Dark" as "clear") Drawer back button: - Back in terminal pane closes the drawer first if drawer mode is active and the drawer is open; otherwise falls back to terminal → nav host - Drawer state/scope declarations moved above BackHandler Session drawer mode (already in progress pre-session): - SessionDrawerBar (hamburger + active label + kebab) and SessionDrawerContent (session list with type-colored rows) composables - New strings: switch_to_top_bar, switch_to_drawer, open_drawer, sessions_header (EN, ES, SV) Mini numpad: - widthPercent 10 → 15 in all three layout JSONs (EN, ES, SV) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bda8967ab1
commit
f6f0e5e078
13 changed files with 534 additions and 26 deletions
|
|
@ -30,8 +30,8 @@ android {
|
|||
defaultConfig {
|
||||
minSdk = 27
|
||||
targetSdk = 36
|
||||
versionCode = 11
|
||||
versionName = "0.0.11"
|
||||
versionCode = 20
|
||||
versionName = "0.0.20"
|
||||
|
||||
applicationId = "com.roundingmobile.sshworkbench"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@ package com.roundingmobile.sshworkbench
|
|||
|
||||
// Auto-generated — do not edit
|
||||
object BuildTimestamp {
|
||||
const val TIME = "2026-04-06 01:54:13"
|
||||
const val TIME = "2026-04-06 11:23:39"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,10 +19,16 @@ import androidx.appcompat.app.AppCompatActivity
|
|||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.Snackbar
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
|
|
@ -43,6 +49,7 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -74,6 +81,8 @@ import com.roundingmobile.sshworkbench.terminal.TerminalService
|
|||
import com.roundingmobile.sshworkbench.ui.navigation.Routes
|
||||
import com.roundingmobile.sshworkbench.ui.navigation.SshWorkbenchNavGraph
|
||||
import com.roundingmobile.sshworkbench.ui.screens.BiometricLockScreen
|
||||
import com.roundingmobile.sshworkbench.ui.screens.SessionDrawerBar
|
||||
import com.roundingmobile.sshworkbench.ui.screens.SessionDrawerContent
|
||||
import com.roundingmobile.sshworkbench.ui.screens.SessionTabBar
|
||||
import com.roundingmobile.sshworkbench.ui.screens.SftpScreen
|
||||
import com.roundingmobile.sshworkbench.ui.screens.TerminalPane
|
||||
|
|
@ -274,6 +283,7 @@ class MainActivity : AppCompatActivity() {
|
|||
val sessionCounts by mainViewModel.sessionCounts.collectAsStateWithLifecycle()
|
||||
val sessionLabels by mainViewModel.sessionLabels.collectAsStateWithLifecycle()
|
||||
val sessionThemes by mainViewModel.sessionThemes.collectAsStateWithLifecycle()
|
||||
val globalThemeName by mainViewModel.globalThemeName.collectAsStateWithLifecycle()
|
||||
val pendingQrKey by mainViewModel.pendingQrKey.collectAsStateWithLifecycle()
|
||||
val dialogRequest by mainViewModel.dialogRequest.collectAsStateWithLifecycle()
|
||||
val navController = rememberNavController()
|
||||
|
|
@ -311,6 +321,10 @@ class MainActivity : AppCompatActivity() {
|
|||
val qbColorCustom = if (isCustomKeyboard) cqbColorCustom else aqbColorCustom
|
||||
val isVerticalQb = qbPosition == "vertical_left" || qbPosition == "vertical_right"
|
||||
val quickBarSize = rawQbSize
|
||||
// Session navigation style preference
|
||||
val sessionNavStyle by mainViewModel.terminalPrefs.sessionNavStyle.collectAsStateWithLifecycle("top_bar")
|
||||
val isDrawerMode = sessionNavStyle == "drawer"
|
||||
|
||||
// Temporary hide state — tapping terminal or navigating to terminal resets it
|
||||
var ckbHidden by remember { mutableStateOf(false) }
|
||||
// Keyboard settings dialog state (opened from CKB gear menu)
|
||||
|
|
@ -440,10 +454,19 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
// Back handler: terminal → connection list
|
||||
// Drawer state for session drawer mode (declared early so BackHandler can reference it)
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val drawerScope = rememberCoroutineScope()
|
||||
|
||||
// Back handler: drawer open → close drawer; else terminal → connection list
|
||||
BackHandler(enabled = appState.currentPane == Pane.TERMINAL) {
|
||||
FileLogger.log(TAG, "BackHandler: TERMINAL → NAV_HOST, activeSession=${appState.activeSessionId}") // TRACE
|
||||
mainViewModel.switchToNavHost()
|
||||
if (isDrawerMode && drawerState.isOpen) {
|
||||
FileLogger.log(TAG, "BackHandler: closing drawer") // TRACE
|
||||
drawerScope.launch { drawerState.close() }
|
||||
} else {
|
||||
FileLogger.log(TAG, "BackHandler: TERMINAL → NAV_HOST, activeSession=${appState.activeSessionId}") // TRACE
|
||||
mainViewModel.switchToNavHost()
|
||||
}
|
||||
}
|
||||
|
||||
// In-app disconnect snackbar
|
||||
|
|
@ -547,19 +570,49 @@ class MainActivity : AppCompatActivity() {
|
|||
)
|
||||
}
|
||||
|
||||
// Shared lambdas for session navigation (used by tab bar, drawer, and drawer overlay)
|
||||
val onNewSession = {
|
||||
mainViewModel.switchToNavHost()
|
||||
navController.navigate(Routes.PICK_HOST) { launchSingleTop = true }
|
||||
}
|
||||
val onSwitchNavStyle = { style: String ->
|
||||
lifecycleScope.launch { mainViewModel.terminalPrefs.setSessionNavStyle(style) }
|
||||
Unit
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
// --- Layer 1: Terminal surfaces + tab bar + shared keyboard ---
|
||||
// Always in the tree — visibility toggled. Views survive navigation.
|
||||
if (tabOrder.isNotEmpty()) {
|
||||
val showTerminal = appState.currentPane == Pane.TERMINAL
|
||||
|
||||
// Terminal content Column (reused in both modes)
|
||||
@Composable fun TerminalColumn() {
|
||||
Column(
|
||||
modifier = if (showTerminal) Modifier.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
else Modifier.fillMaxSize().alpha(0f)
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
) {
|
||||
// Session tab bar
|
||||
// Session tab bar or drawer bar
|
||||
if (showTerminal) {
|
||||
if (isDrawerMode) {
|
||||
SessionDrawerBar(
|
||||
activeLabel = sessionLabels[appState.activeSessionId] ?: "",
|
||||
tabType = tabTypes[appState.activeSessionId] ?: TabType.TERMINAL,
|
||||
onHamburger = { drawerScope.launch { drawerState.open() } },
|
||||
onPlusTap = onNewSession,
|
||||
onKbSettings = {
|
||||
if (isCustomKeyboard) showKbSettings = true
|
||||
else showAqbSettings = true
|
||||
},
|
||||
kbSettingsLabel = if (isCustomKeyboard)
|
||||
getString(R.string.keyboard_settings_title)
|
||||
else getString(R.string.quick_bar_settings),
|
||||
ckbVisible = isCustomKeyboard && !ckbHidden,
|
||||
onToggleCkb = { ckbHidden = !ckbHidden }
|
||||
)
|
||||
} else {
|
||||
SessionTabBar(
|
||||
sessions = activeSessions,
|
||||
tabOrder = tabOrder,
|
||||
|
|
@ -567,10 +620,7 @@ class MainActivity : AppCompatActivity() {
|
|||
activeSessionId = appState.activeSessionId,
|
||||
sessionLabels = sessionLabels,
|
||||
onSessionTap = { sid -> mainViewModel.switchToTerminal(sid) },
|
||||
onPlusTap = {
|
||||
mainViewModel.switchToNavHost()
|
||||
navController.navigate(Routes.PICK_HOST) { launchSingleTop = true }
|
||||
},
|
||||
onPlusTap = onNewSession,
|
||||
onDuplicate = { sid -> mainViewModel.duplicateSession(sid) },
|
||||
onSftp = { sid ->
|
||||
val connId = mainViewModel.terminalService?.getSession(sid)?.savedConnectionId ?: return@SessionTabBar
|
||||
|
|
@ -582,6 +632,7 @@ class MainActivity : AppCompatActivity() {
|
|||
onTheme = { sid, theme -> mainViewModel.setSessionTheme(sid, theme) },
|
||||
sftpCapable = mainViewModel.getSftpCapableSessions(),
|
||||
sessionThemes = sessionThemes,
|
||||
globalThemeName = globalThemeName,
|
||||
onKbSettings = {
|
||||
if (isCustomKeyboard) showKbSettings = true
|
||||
else showAqbSettings = true
|
||||
|
|
@ -590,8 +641,10 @@ class MainActivity : AppCompatActivity() {
|
|||
getString(R.string.keyboard_settings_title)
|
||||
else getString(R.string.quick_bar_settings),
|
||||
ckbVisible = isCustomKeyboard && !ckbHidden,
|
||||
onToggleCkb = { ckbHidden = !ckbHidden }
|
||||
onToggleCkb = { ckbHidden = !ckbHidden },
|
||||
onSwitchToDrawer = { onSwitchNavStyle("drawer") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- QB composable (reused in different positions) ---
|
||||
|
|
@ -785,6 +838,10 @@ class MainActivity : AppCompatActivity() {
|
|||
)
|
||||
} // key(keyboardLanguage, numberRowMode)
|
||||
}
|
||||
} // end TerminalColumn
|
||||
|
||||
// Render terminal content — drawer overlay managed separately (no swipe gesture)
|
||||
TerminalColumn()
|
||||
}
|
||||
|
||||
// --- SFTP screens — rendered at Box level for proper edge-to-edge insets ---
|
||||
|
|
@ -821,6 +878,72 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Session drawer overlay (no swipe gesture — hamburger only) ---
|
||||
if (isDrawerMode && tabOrder.isNotEmpty()) {
|
||||
val showTerminal = appState.currentPane == Pane.TERMINAL
|
||||
val drawerOpen = drawerState.isOpen
|
||||
// Scrim
|
||||
if (showTerminal && drawerOpen) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
.zIndex(3f)
|
||||
.clickable { drawerScope.launch { drawerState.close() } }
|
||||
)
|
||||
}
|
||||
// Drawer panel
|
||||
AnimatedVisibility(
|
||||
visible = showTerminal && drawerOpen,
|
||||
enter = slideInHorizontally { -it },
|
||||
exit = slideOutHorizontally { -it },
|
||||
modifier = Modifier.zIndex(4f)
|
||||
) {
|
||||
SessionDrawerContent(
|
||||
sessions = activeSessions,
|
||||
tabOrder = tabOrder,
|
||||
tabTypes = tabTypes,
|
||||
activeSessionId = appState.activeSessionId,
|
||||
sessionLabels = sessionLabels,
|
||||
onSessionTap = { sid ->
|
||||
mainViewModel.switchToTerminal(sid)
|
||||
drawerScope.launch { drawerState.close() }
|
||||
},
|
||||
onPlusTap = {
|
||||
drawerScope.launch { drawerState.close() }
|
||||
onNewSession()
|
||||
},
|
||||
onSwitchToTopBar = {
|
||||
drawerScope.launch { drawerState.close() }
|
||||
onSwitchNavStyle("top_bar")
|
||||
},
|
||||
onDuplicate = { sid ->
|
||||
drawerScope.launch { drawerState.close() }
|
||||
mainViewModel.duplicateSession(sid)
|
||||
},
|
||||
onSftp = { sid ->
|
||||
drawerScope.launch { drawerState.close() }
|
||||
val connId = mainViewModel.terminalService?.getSession(sid)?.savedConnectionId ?: return@SessionDrawerContent
|
||||
mainViewModel.openSftpTab(connId)
|
||||
},
|
||||
onConnectTerminal = { sid ->
|
||||
drawerScope.launch { drawerState.close() }
|
||||
mainViewModel.connectTerminalFromSftp(sid)
|
||||
},
|
||||
onClose = { sid -> mainViewModel.disconnectSession(sid) },
|
||||
onRename = { sid, name ->
|
||||
mainViewModel.renameSession(sid, name)
|
||||
},
|
||||
onTheme = { sid, theme ->
|
||||
mainViewModel.setSessionTheme(sid, theme)
|
||||
},
|
||||
sftpCapable = mainViewModel.getSftpCapableSessions(),
|
||||
sessionThemes = sessionThemes,
|
||||
globalThemeName = globalThemeName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Layer 2: NavHost — visible when not in terminal ---
|
||||
AnimatedVisibility(
|
||||
visible = appState.currentPane == Pane.NAV_HOST,
|
||||
|
|
|
|||
|
|
@ -157,6 +157,10 @@ class MainViewModel @Inject constructor(
|
|||
private val _sessionThemes = MutableStateFlow<Map<Long, String>>(emptyMap())
|
||||
val sessionThemes: StateFlow<Map<Long, String>> = _sessionThemes.asStateFlow()
|
||||
|
||||
/** Global default theme name from Settings — used as the fallback label when no override is set. */
|
||||
val globalThemeName: StateFlow<String> = terminalPrefs.themeName
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "Default Dark")
|
||||
|
||||
// --- Per-session font size (seeded from Room into SessionEntry on load) ---
|
||||
|
||||
private val dupNumRegex = Regex("\\((\\d+)\\)$")
|
||||
|
|
@ -675,14 +679,14 @@ class MainViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/** Set per-session theme override. Persisted in service entry. */
|
||||
/** Set per-session theme override. Empty string clears the override (use global default). */
|
||||
fun setSessionTheme(sessionId: Long, themeName: String) {
|
||||
FileLogger.log(TAG, "setSessionTheme: $sessionId → '$themeName'") // TRACE
|
||||
val theme = if (themeName == "Default Dark") null else themeName
|
||||
val theme = themeName.ifBlank { null }
|
||||
terminalService?.getSession(sessionId)?.customThemeName = theme
|
||||
_sessionThemes.update {
|
||||
if (theme == null) it - sessionId
|
||||
else it + (sessionId to themeName)
|
||||
else it + (sessionId to theme)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -705,12 +705,14 @@ fun EditConnectionScreen(
|
|||
)
|
||||
|
||||
var themeDropdownExpanded by remember { mutableStateOf(false) }
|
||||
val globalThemeName by viewModel.globalThemeName.collectAsStateWithLifecycle()
|
||||
val defaultLabel = stringResource(R.string.default_theme_label, globalThemeName)
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = themeDropdownExpanded,
|
||||
onExpandedChange = { themeDropdownExpanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = viewModel.themeName.ifBlank { stringResource(R.string.default_theme_label, "Default Dark") },
|
||||
value = viewModel.themeName.ifBlank { defaultLabel },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(R.string.theme)) },
|
||||
|
|
@ -725,6 +727,13 @@ fun EditConnectionScreen(
|
|||
expanded = themeDropdownExpanded,
|
||||
onDismissRequest = { themeDropdownExpanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(defaultLabel) },
|
||||
onClick = {
|
||||
viewModel.themeName = ""
|
||||
themeDropdownExpanded = false
|
||||
}
|
||||
)
|
||||
TERMINAL_THEMES.forEach { theme ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(theme) },
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Column
|
|||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -20,15 +21,18 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
|||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material.icons.filled.KeyboardHide
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
|
|
@ -77,10 +81,8 @@ private val ChipDisconnectedTeal = Color(0xFF152228)
|
|||
private val ChipDisconnectedAmber = Color(0xFF221C10)
|
||||
private val ChipDisconnectedViolet = Color(0xFF1E1422)
|
||||
|
||||
private val THEME_NAMES = listOf(
|
||||
"Default Dark", "Dracula", "Monokai", "Nord",
|
||||
"Solarized Dark", "Solarized Light", "Gruvbox"
|
||||
)
|
||||
private val THEME_NAMES: List<String> =
|
||||
com.roundingmobile.terminalview.TerminalTheme.builtInThemes.map { it.name }
|
||||
|
||||
/**
|
||||
* Shared Compose session tab bar — used in Layer 3 of the single-Activity architecture.
|
||||
|
|
@ -110,6 +112,9 @@ fun SessionTabBar(
|
|||
sftpCapable: Set<Long> = emptySet(),
|
||||
/** Current per-session theme overrides (sessionId -> themeName) */
|
||||
sessionThemes: Map<Long, String> = emptyMap(),
|
||||
/** Global default theme name (shown as "Default (…)" in the picker when a session has no override) */
|
||||
globalThemeName: String = "Default Dark",
|
||||
onSwitchToDrawer: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
|
|
@ -152,6 +157,7 @@ fun SessionTabBar(
|
|||
isConnected = isConnected,
|
||||
sftpCapable = !isSftp && sftpCapable.contains(sid),
|
||||
currentTheme = sessionThemes[sid] ?: "",
|
||||
globalThemeName = globalThemeName,
|
||||
onTap = { onSessionTap(sid) },
|
||||
onDuplicate = { onDuplicate(sid) },
|
||||
onSftp = { onSftp(sid) },
|
||||
|
|
@ -200,6 +206,13 @@ fun SessionTabBar(
|
|||
onClick = { showBarMenu = false; onKbSettings() },
|
||||
leadingIcon = { Icon(Icons.Filled.Settings, contentDescription = null) }
|
||||
)
|
||||
if (onSwitchToDrawer != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.switch_to_drawer)) },
|
||||
onClick = { showBarMenu = false; onSwitchToDrawer() },
|
||||
leadingIcon = { Icon(Icons.Filled.Menu, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -213,6 +226,7 @@ private fun SessionChip(
|
|||
isConnected: Boolean,
|
||||
sftpCapable: Boolean,
|
||||
currentTheme: String,
|
||||
globalThemeName: String,
|
||||
onTap: () -> Unit,
|
||||
onDuplicate: () -> Unit,
|
||||
onSftp: () -> Unit,
|
||||
|
|
@ -365,6 +379,7 @@ private fun SessionChip(
|
|||
if (showThemeDialog) {
|
||||
ThemePickerDialog(
|
||||
currentTheme = currentTheme,
|
||||
globalThemeName = globalThemeName,
|
||||
onSelect = { theme -> showThemeDialog = false; onTheme(theme) },
|
||||
onDismiss = { showThemeDialog = false }
|
||||
)
|
||||
|
|
@ -407,17 +422,30 @@ private fun RenameDialog(
|
|||
@Composable
|
||||
private fun ThemePickerDialog(
|
||||
currentTheme: String,
|
||||
globalThemeName: String,
|
||||
onSelect: (String) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val defaultLabel = stringResource(R.string.default_theme_label, globalThemeName)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.session_theme_title)) },
|
||||
text = {
|
||||
Column {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
// "Default" entry — clears the per-session override
|
||||
val defaultSelected = currentTheme.isBlank()
|
||||
Text(
|
||||
text = defaultLabel,
|
||||
color = if (defaultSelected) AccentTeal else TextPrimary,
|
||||
fontWeight = if (defaultSelected) FontWeight.Bold else FontWeight.Normal,
|
||||
fontSize = 15.sp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelect("") }
|
||||
.padding(vertical = 10.dp, horizontal = 4.dp)
|
||||
)
|
||||
for (theme in THEME_NAMES) {
|
||||
val isSelected = theme == currentTheme ||
|
||||
(currentTheme.isBlank() && theme == "Default Dark")
|
||||
val isSelected = theme == currentTheme
|
||||
Text(
|
||||
text = theme,
|
||||
color = if (isSelected) AccentTeal else TextPrimary,
|
||||
|
|
@ -434,3 +462,331 @@ private fun ThemePickerDialog(
|
|||
confirmButton = {}
|
||||
)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Drawer mode — hamburger bar + drawer content panel
|
||||
// ========================================================================
|
||||
|
||||
private val DrawerBg = Color(0xFF0D1117)
|
||||
private val DrawerWidth = 260.dp
|
||||
private val DrawerSectionHeader = Color(0xFF888888)
|
||||
|
||||
/**
|
||||
* Minimal header bar replacing SessionTabBar when drawer mode is active.
|
||||
* Shows hamburger icon + active session label + kebab menu.
|
||||
*/
|
||||
@Composable
|
||||
fun SessionDrawerBar(
|
||||
activeLabel: String,
|
||||
tabType: com.roundingmobile.sshworkbench.ui.TabType = com.roundingmobile.sshworkbench.ui.TabType.TERMINAL,
|
||||
onHamburger: () -> Unit,
|
||||
onPlusTap: () -> Unit,
|
||||
onKbSettings: () -> Unit = {},
|
||||
kbSettingsLabel: String = "",
|
||||
ckbVisible: Boolean = false,
|
||||
onToggleCkb: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val typeAccent = when (tabType) {
|
||||
com.roundingmobile.sshworkbench.ui.TabType.SFTP -> AccentAmber
|
||||
com.roundingmobile.sshworkbench.ui.TabType.TELNET -> AccentViolet
|
||||
else -> AccentTeal
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(TabBarBg)
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.height(41.dp)
|
||||
.padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onHamburger) {
|
||||
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.open_drawer), tint = AccentTeal)
|
||||
}
|
||||
Text(
|
||||
text = activeLabel,
|
||||
color = typeAccent,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f).padding(horizontal = 4.dp)
|
||||
)
|
||||
// Kebab menu (same items as tab bar)
|
||||
var showBarMenu by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(onClick = { showBarMenu = true }) {
|
||||
Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options), tint = AccentTeal)
|
||||
}
|
||||
DropdownMenu(expanded = showBarMenu, onDismissRequest = { showBarMenu = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.new_session)) },
|
||||
onClick = { showBarMenu = false; onPlusTap() },
|
||||
leadingIcon = { Icon(Icons.Filled.Add, contentDescription = null, tint = AccentTeal) }
|
||||
)
|
||||
if (ckbVisible) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.hide_keyboard)) },
|
||||
onClick = { showBarMenu = false; onToggleCkb() },
|
||||
leadingIcon = { Icon(Icons.Filled.KeyboardHide, contentDescription = null) }
|
||||
)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.show_keyboard)) },
|
||||
onClick = { showBarMenu = false; onToggleCkb() },
|
||||
leadingIcon = { Icon(Icons.Filled.Keyboard, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(kbSettingsLabel.ifEmpty { stringResource(R.string.quick_bar_settings) }) },
|
||||
onClick = { showBarMenu = false; onKbSettings() },
|
||||
leadingIcon = { Icon(Icons.Filled.Settings, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drawer content panel — session list with type-colored rows,
|
||||
* per-session overflow menus, new session action, and switch-to-top-bar footer.
|
||||
*/
|
||||
@Composable
|
||||
fun SessionDrawerContent(
|
||||
sessions: Map<Long, SessionState>,
|
||||
tabOrder: List<Long>,
|
||||
tabTypes: Map<Long, com.roundingmobile.sshworkbench.ui.TabType>,
|
||||
activeSessionId: Long,
|
||||
sessionLabels: Map<Long, String>,
|
||||
onSessionTap: (Long) -> Unit,
|
||||
onPlusTap: () -> Unit,
|
||||
onSwitchToTopBar: () -> Unit,
|
||||
onDuplicate: (Long) -> Unit = {},
|
||||
onSftp: (Long) -> Unit = {},
|
||||
onConnectTerminal: (Long) -> Unit = {},
|
||||
onClose: (Long) -> Unit = {},
|
||||
onRename: (Long, String) -> Unit = { _, _ -> },
|
||||
onTheme: (Long, String) -> Unit = { _, _ -> },
|
||||
sftpCapable: Set<Long> = emptySet(),
|
||||
sessionThemes: Map<Long, String> = emptyMap(),
|
||||
globalThemeName: String = "Default Dark",
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.width(DrawerWidth)
|
||||
.fillMaxHeight()
|
||||
.background(DrawerBg)
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.padding(top = 16.dp)
|
||||
) {
|
||||
// Header
|
||||
Text(
|
||||
text = "SSH Workbench",
|
||||
color = AccentTeal,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
|
||||
// Sessions header
|
||||
Text(
|
||||
text = stringResource(R.string.sessions_header),
|
||||
color = DrawerSectionHeader,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
)
|
||||
|
||||
// Session list
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
for (sid in tabOrder) {
|
||||
val tabType = tabTypes[sid] ?: com.roundingmobile.sshworkbench.ui.TabType.TERMINAL
|
||||
val isSftp = tabType == com.roundingmobile.sshworkbench.ui.TabType.SFTP
|
||||
if (!isSftp && sid !in sessions) continue
|
||||
|
||||
DrawerSessionRow(
|
||||
sid = sid,
|
||||
label = sessionLabels[sid] ?: stringResource(R.string.session_fallback_label, sid),
|
||||
tabType = tabType,
|
||||
isActive = sid == activeSessionId,
|
||||
isConnected = isSftp || sessions[sid] is SessionState.Connected,
|
||||
sftpCapable = !isSftp && sftpCapable.contains(sid),
|
||||
currentTheme = sessionThemes[sid] ?: "",
|
||||
globalThemeName = globalThemeName,
|
||||
onTap = { onSessionTap(sid) },
|
||||
onDuplicate = { onDuplicate(sid) },
|
||||
onSftp = { onSftp(sid) },
|
||||
onConnectTerminal = { onConnectTerminal(sid) },
|
||||
onRename = { name -> onRename(sid, name) },
|
||||
onTheme = { theme -> onTheme(sid, theme) },
|
||||
onClose = { onClose(sid) }
|
||||
)
|
||||
}
|
||||
|
||||
// + New session
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onPlusTap)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null, tint = AccentTeal, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.new_session_label),
|
||||
color = AccentTeal,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Footer divider + switch to top bar
|
||||
HorizontalDivider(color = Color(0xFF2A2A2A))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onSwitchToTopBar)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.switch_to_top_bar),
|
||||
color = DrawerSectionHeader,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DrawerSessionRow(
|
||||
sid: Long,
|
||||
label: String,
|
||||
tabType: com.roundingmobile.sshworkbench.ui.TabType,
|
||||
isActive: Boolean,
|
||||
isConnected: Boolean,
|
||||
sftpCapable: Boolean,
|
||||
currentTheme: String,
|
||||
globalThemeName: String,
|
||||
onTap: () -> Unit,
|
||||
onDuplicate: () -> Unit,
|
||||
onSftp: () -> Unit,
|
||||
onConnectTerminal: () -> Unit,
|
||||
onRename: (String) -> Unit,
|
||||
onTheme: (String) -> Unit,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
val isSftp = tabType == com.roundingmobile.sshworkbench.ui.TabType.SFTP
|
||||
val typeAccent = when (tabType) {
|
||||
com.roundingmobile.sshworkbench.ui.TabType.SFTP -> AccentAmber
|
||||
com.roundingmobile.sshworkbench.ui.TabType.TELNET -> AccentViolet
|
||||
else -> AccentTeal
|
||||
}
|
||||
val rowBg = if (isActive) Color(0xFF1A2530) else Color.Transparent
|
||||
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var showRenameDialog by remember { mutableStateOf(false) }
|
||||
var showThemeDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Box {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(rowBg)
|
||||
.clickable(onClick = onTap)
|
||||
.padding(start = 16.dp, top = 6.dp, bottom = 6.dp, end = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Connected dot or red dot
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.background(
|
||||
if (isConnected) typeAccent else AccentRed,
|
||||
CircleShape
|
||||
)
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = label,
|
||||
color = if (isActive) typeAccent else TextPrimary,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
// 3-dot overflow menu
|
||||
Icon(
|
||||
Icons.Filled.MoreVert,
|
||||
contentDescription = null,
|
||||
tint = DrawerSectionHeader,
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.clickable { showMenu = true }
|
||||
.padding(start = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
if (isConnected && !isSftp) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.session_duplicate)) },
|
||||
onClick = { showMenu = false; onDuplicate() }
|
||||
)
|
||||
}
|
||||
if (isConnected && sftpCapable) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.session_connect_sftp)) },
|
||||
onClick = { showMenu = false; onSftp() }
|
||||
)
|
||||
}
|
||||
if (isSftp) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.session_connect_terminal)) },
|
||||
onClick = { showMenu = false; onConnectTerminal() }
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.session_rename)) },
|
||||
onClick = { showMenu = false; showRenameDialog = true }
|
||||
)
|
||||
if (!isSftp) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.session_theme)) },
|
||||
onClick = { showMenu = false; showThemeDialog = true }
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.session_close)) },
|
||||
onClick = { showMenu = false; onClose() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showRenameDialog) {
|
||||
RenameDialog(
|
||||
currentName = label,
|
||||
onConfirm = { newName -> showRenameDialog = false; onRename(newName) },
|
||||
onDismiss = { showRenameDialog = false }
|
||||
)
|
||||
}
|
||||
if (showThemeDialog) {
|
||||
ThemePickerDialog(
|
||||
currentTheme = currentTheme,
|
||||
globalThemeName = globalThemeName,
|
||||
onSelect = { theme -> showThemeDialog = false; onTheme(theme) },
|
||||
onDismiss = { showThemeDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,10 @@ class EditConnectionViewModel @Inject constructor(
|
|||
val savedConnections: StateFlow<List<SavedConnection>> = connectionDao.getAll()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
/** Global default theme name from Settings — used as the fallback label when a connection has no override. */
|
||||
val globalThemeName: StateFlow<String> = terminalPreferences.themeName
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "Default Dark")
|
||||
|
||||
private val _connectSignal = MutableSharedFlow<Long>()
|
||||
val connectSignal: SharedFlow<Long> = _connectSignal.asSharedFlow()
|
||||
|
||||
|
|
|
|||
|
|
@ -365,6 +365,10 @@
|
|||
<string name="session_nav_top_bar">Barra superior</string>
|
||||
<string name="session_nav_drawer">Panel lateral</string>
|
||||
<string name="show_session_tab_bar">Mostrar barra de sesiones</string>
|
||||
<string name="switch_to_top_bar">Cambiar a barra superior</string>
|
||||
<string name="switch_to_drawer">Cambiar a panel lateral</string>
|
||||
<string name="open_drawer">Abrir panel</string>
|
||||
<string name="sessions_header">SESIONES</string>
|
||||
<string name="new_session_label">Nueva sesión</string>
|
||||
<string name="session_duplicate">Duplicar</string>
|
||||
<string name="session_close">Cerrar</string>
|
||||
|
|
|
|||
|
|
@ -364,6 +364,10 @@
|
|||
<string name="session_nav_top_bar">Övre fält</string>
|
||||
<string name="session_nav_drawer">Sidopanel</string>
|
||||
<string name="show_session_tab_bar">Visa sessionsfält</string>
|
||||
<string name="switch_to_top_bar">Byt till övre fält</string>
|
||||
<string name="switch_to_drawer">Byt till sidopanel</string>
|
||||
<string name="open_drawer">Öppna panel</string>
|
||||
<string name="sessions_header">SESSIONER</string>
|
||||
<string name="new_session_label">Ny session</string>
|
||||
<string name="session_duplicate">Duplicera</string>
|
||||
<string name="session_close">Stäng</string>
|
||||
|
|
|
|||
|
|
@ -385,6 +385,10 @@
|
|||
<string name="session_nav_top_bar">Top bar</string>
|
||||
<string name="session_nav_drawer">Drawer</string>
|
||||
<string name="show_session_tab_bar">Show session tab bar</string>
|
||||
<string name="switch_to_top_bar">Switch to top bar</string>
|
||||
<string name="switch_to_drawer">Switch to drawer</string>
|
||||
<string name="open_drawer">Open drawer</string>
|
||||
<string name="sessions_header">SESSIONS</string>
|
||||
<string name="new_session_label">New session</string>
|
||||
<string name="session_duplicate">Duplicate</string>
|
||||
<string name="session_close">Close</string>
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@
|
|||
],
|
||||
|
||||
"mini": {
|
||||
"widthPercent": 10,
|
||||
"widthPercent": 15,
|
||||
"rows": [
|
||||
{ "keys": [
|
||||
{ "id": "m1", "label": "1", "action": { "type": "char", "primary": "1", "shift": "!" } },
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@
|
|||
],
|
||||
|
||||
"mini": {
|
||||
"widthPercent": 10,
|
||||
"widthPercent": 15,
|
||||
"rows": [
|
||||
{ "keys": [
|
||||
{ "id": "m1", "label": "1", "action": { "type": "char", "primary": "1", "shift": "!" } },
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@
|
|||
],
|
||||
|
||||
"mini": {
|
||||
"widthPercent": 10,
|
||||
"widthPercent": 15,
|
||||
"rows": [
|
||||
{ "keys": [
|
||||
{ "id": "m1", "label": "1", "action": { "type": "char", "primary": "1", "shift": "!" } },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue