Time format setting, protocol-aware card colors, local shell proper IDs, startup commands fix

- Time Format setting (Device default / 12h / 24h) under Display
- Protocol-aware connection card accents: green SSH, amber SFTP, violet Telnet, sky blue Local
- Local shell uses generated sessionId/savedConnectionId (was hardcoded 0)
- Local shell startup commands + lastOutputTimeNs silence detection
- SFTP updates lastConnected for sort order; SFTP option hidden for non-SSH
- Quick connect bar colors unified with lock/key (#7A8888), purged #3E4949
- Pulse animation tuned to 100%→60% over 2s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jima 2026-04-10 17:07:34 +02:00
parent 7f4aa15830
commit d591291c28
15 changed files with 904 additions and 325 deletions

View file

@ -2,5 +2,5 @@ package com.roundingmobile.sshworkbench
// Auto-generated — do not edit
object BuildTimestamp {
const val TIME = "2026-04-06 19:48:44"
const val TIME = "2026-04-10 16:56:29"
}

View file

@ -24,6 +24,7 @@ object TerminalPrefsKeys {
val KEYBOARD_LANGUAGE = stringPreferencesKey("keyboard_language")
val HAPTIC_FEEDBACK = booleanPreferencesKey("haptic_feedback")
val KEEP_SCREEN_ON = booleanPreferencesKey("keep_screen_on")
val TIME_FORMAT = stringPreferencesKey("time_format")
val QUICK_BAR_VISIBLE = booleanPreferencesKey("quick_bar_visible")
val BIOMETRIC_LOCK = booleanPreferencesKey("biometric_lock")
val AUTO_RECONNECT = booleanPreferencesKey("auto_reconnect")
@ -134,6 +135,14 @@ class TerminalPreferences(private val dataStore: DataStore<Preferences>, private
dataStore.edit { prefs -> prefs[TerminalPrefsKeys.KEEP_SCREEN_ON] = enabled }
}
val timeFormat: Flow<String> = dataStore.data.map { prefs ->
prefs[TerminalPrefsKeys.TIME_FORMAT] ?: "system"
}
suspend fun setTimeFormat(format: String) {
dataStore.edit { prefs -> prefs[TerminalPrefsKeys.TIME_FORMAT] = format }
}
val cursorSpeed: Flow<String> = dataStore.data.map { prefs ->
prefs[TerminalPrefsKeys.CURSOR_SPEED] ?: "normal"
}

View file

@ -988,22 +988,19 @@ class TerminalService : Service() {
// Local shell
// ========================================================================
fun startLocalShell(rows: Int = 24, cols: Int = 80, onScreenUpdated: (() -> Unit)? = null, scrollbackLines: Int = 10000): Long {
FileLogger.log(TAG, "startLocalShell")
fun startLocalShell(sessionId: Long, savedConnectionId: Long = 0L, rows: Int = 24, cols: Int = 80, onScreenUpdated: (() -> Unit)? = null, scrollbackLines: Int = 10000): Long {
FileLogger.log(TAG, "startLocalShell sid=$sessionId connId=$savedConnectionId")
// Re-register started component (see ensureStarted doc)
ensureStarted()
// Local shell always uses session ID 0
disconnectSession(0L)
val buf = ScreenBuffer(rows, cols, maxScrollback = scrollbackLines)
val parser = XtermParser(buf)
val localSession = LocalShellSession()
val entry = SessionEntry(
sessionId = 0L,
savedConnectionId = 0L,
sessionId = sessionId,
savedConnectionId = savedConnectionId,
sshSession = null,
localShellSession = localSession,
screenBuffer = buf,
@ -1016,19 +1013,20 @@ class TerminalService : Service() {
setupTerminalListener(entry)
entry.onScreenUpdated = onScreenUpdated
if (com.roundingmobile.sshworkbench.BuildConfig.DEBUG) {
entry.recorder = com.roundingmobile.sshworkbench.util.SessionRecorder.start("s0_local_shell")
entry.recorder = com.roundingmobile.sshworkbench.util.SessionRecorder.start("s${sessionId}_local_shell")
}
sessions[0L] = entry
sessions[sessionId] = entry
entry.outputJob = serviceScope.launch(Dispatchers.IO) {
localSession.output.collect { bytes ->
entry.lastOutputTimeNs = System.nanoTime()
entry.recorder?.recordChunk(bytes)
if (bytes.size < 128) {
val hex = bytes.joinToString(" ") { "%02X".format(it) }
val curBefore = "col=${entry.screenBuffer.cursorCol}"
synchronized(entry.parserLock) { entry.parser.process(bytes) }
val curAfter = "col=${entry.screenBuffer.cursorCol}"
FileLogger.log(TAG, "local-chunk[0:${bytes.size}] $curBefore$curAfter: $hex")
FileLogger.log(TAG, "local-chunk[$sessionId:${bytes.size}] $curBefore$curAfter: $hex")
} else {
synchronized(entry.parserLock) { entry.parser.process(bytes) }
}
@ -1040,20 +1038,24 @@ class TerminalService : Service() {
entry.stateJob = serviceScope.launch {
localSession.state.collect { state ->
FileLogger.log(TAG, "LocalShell state: $state")
FileLogger.log(TAG, "LocalShell[$sessionId] state: $state")
when (state) {
is LocalShellSession.State.Running -> {
updateSessionState(0L, SessionState.Connected)
updateSessionState(sessionId, SessionState.Connected)
updateNotification()
acquireWakeLock()
// Execute startup commands
serviceScope.launch(Dispatchers.IO) {
startupCommandRunner.execute(entry, ::writeInput)
}
}
is LocalShellSession.State.Stopped -> {
updateSessionState(0L, SessionState.Disconnected("Shell exited", cleanExit = true))
cleanupDisconnectedSession(0L)
updateSessionState(sessionId, SessionState.Disconnected("Shell exited", cleanExit = true))
cleanupDisconnectedSession(sessionId)
}
is LocalShellSession.State.Error -> {
updateSessionState(0L, SessionState.Error(state.message))
cleanupDisconnectedSession(0L)
updateSessionState(sessionId, SessionState.Error(state.message))
cleanupDisconnectedSession(sessionId)
}
is LocalShellSession.State.Idle -> {}
}
@ -1061,7 +1063,7 @@ class TerminalService : Service() {
}
localSession.start(buf.cols, buf.rows)
return 0L
return sessionId
}
// ========================================================================

View file

@ -377,7 +377,7 @@ class MainViewModel @Inject constructor(
}
launch(Dispatchers.Main) {
if (proto == "local") {
svc.startLocalShell(24, 80, scrollbackLines = scrollback)
svc.startLocalShell(sessionId, savedConnId, 24, 80, scrollbackLines = scrollback)
// switchToTerminal handled by sessionObserver on new session detection
} else if (proto == "telnet") {
svc.connectTelnet(sessionId, savedConnId, host, port, 24, 80,
@ -452,6 +452,7 @@ class MainViewModel @Inject constructor(
// Resolve label from Room and open SFTP session
viewModelScope.launch(Dispatchers.IO) {
connectionDao.updateLastConnected(connectionId, System.currentTimeMillis())
val saved = connectionDao.getById(connectionId)
val baseLabel = saved?.let { it.nickname.ifBlank { it.name } } ?: "SFTP"
val cleanLabel = baseLabel.replace(Regex(" \\(\\d+\\)$"), "")
@ -773,6 +774,7 @@ class MainViewModel @Inject constructor(
)
} else if (entry.localShellSession != null) {
svc.startLocalShell(
sessionId, entry.savedConnectionId,
entry.screenBuffer.rows, entry.screenBuffer.cols,
scrollbackLines = scrollback
)

View file

@ -1,8 +1,17 @@
package com.roundingmobile.sshworkbench.ui.screens
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -15,15 +24,15 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.ElevatedCard
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
@ -33,20 +42,36 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.foundation.BorderStroke
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.roundingmobile.sshworkbench.R
import androidx.compose.ui.text.font.Font
import com.roundingmobile.sshworkbench.data.local.SavedConnection
import com.roundingmobile.sshworkbench.ui.ConnectionSessionInfo
import kotlinx.coroutines.delay
internal val ActiveGreen = Color(0xFF4CAF50)
// Terminal-style color palette
internal val ActiveGreen = Color(0xFF78DC77)
private val AccentAmber = Color(0xFFFFB020)
private val DisconnectedRed = Color(0xFFE53935)
private val AccentViolet = Color(0xFFB080E0)
private val AccentBlue = Color(0xFF60A5FA)
private val DisconnectedRed = Color(0xFFE05050)
private val CardBg = Color(0xFF181C22)
private val PrimaryTeal = Color(0xFF79DCDC)
private val OnSurface = Color(0xFFDFE2EB)
private val OnSurfaceVariant = Color(0xFFBDC9C8)
private val OutlineVar = Color(0xFF7A8888)
private val SpaceGrotesk = FontFamily(Font(R.font.space_grotesk))
@OptIn(ExperimentalFoundationApi::class)
@Composable
@ -54,6 +79,7 @@ internal fun ConnectionItem(
connection: SavedConnection,
sessionInfo: ConnectionSessionInfo?,
sessionTrackingEnabled: Boolean = true,
timeFormat: String = "system",
highlight: Boolean = false,
onHighlightDone: () -> Unit = {},
onTap: () -> Unit,
@ -61,8 +87,10 @@ internal fun ConnectionItem(
) {
val activeSessionCount = sessionInfo?.totalCount ?: 0
val isActive = activeSessionCount > 0
val cardShape = RoundedCornerShape(12.dp)
val hasConnected = (sessionInfo?.terminalCount ?: 0) > 0 || (sessionInfo?.sftpCount ?: 0) > 0
val cardShape = RoundedCornerShape(8.dp)
// Flash animation for highlight (duplicate/scroll-to)
val flashAlpha = remember { Animatable(0f) }
LaunchedEffect(highlight) {
if (highlight) {
@ -72,265 +100,260 @@ internal fun ConnectionItem(
}
}
ElevatedCard(
// Active color based on session types and protocol
val activeColor = when {
!isActive || !hasConnected -> ActiveGreen // fallback, overridden below
(sessionInfo?.terminalCount ?: 0) > 0 -> when (connection.protocol) {
"telnet" -> AccentViolet
"local" -> AccentBlue
else -> ActiveGreen // SSH
}
(sessionInfo?.sftpCount ?: 0) > 0 -> AccentAmber // SFTP only
else -> ActiveGreen
}
// Pulse animation for active dot
val pulseAlpha = if (isActive && hasConnected) {
val transition = rememberInfiniteTransition(label = "pulse")
transition.animateFloat(
initialValue = 1f,
targetValue = 0.6f,
animationSpec = infiniteRepeatable(
animation = tween(2000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "dotPulse"
).value
} else 1f
// Left accent bar color
val barColor = when {
isActive && hasConnected -> activeColor
isActive -> DisconnectedRed
else -> PrimaryTeal.copy(alpha = 0.4f)
}
// Card border color + width: active gets thicker border
val borderColor = when {
isActive && hasConnected -> activeColor.copy(alpha = 0.35f)
isActive -> DisconnectedRed.copy(alpha = 0.35f)
else -> OutlineVar.copy(alpha = 0.1f)
}
val borderWidth = if (isActive) 2.dp else 1.dp
Card(
modifier = Modifier
.fillMaxWidth()
.then(
if (isActive) {
val barColor = if ((sessionInfo?.terminalCount ?: 0) > 0 || (sessionInfo?.sftpCount ?: 0) > 0) ActiveGreen else DisconnectedRed
Modifier.drawBehind {
drawRect(
color = barColor,
topLeft = Offset.Zero,
size = androidx.compose.ui.geometry.Size(4.dp.toPx(), size.height)
)
}
} else Modifier
)
.combinedClickable(
onClick = onTap,
onLongClick = onLongPress
),
shape = cardShape
shape = cardShape,
colors = CardDefaults.cardColors(containerColor = CardBg),
border = BorderStroke(borderWidth, borderColor),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
// Flash overlay drawn ON TOP of card content
Box {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
Box(
modifier = Modifier.drawWithContent {
drawContent()
// Left accent bar painted on top of card background
drawRect(
color = barColor,
topLeft = Offset.Zero,
size = androidx.compose.ui.geometry.Size(3.dp.toPx(), size.height)
)
}
) {
// Status dot + colored circle
val hasConnected = (sessionInfo?.terminalCount ?: 0) > 0 || (sessionInfo?.sftpCount ?: 0) > 0
val circleColor = if (isActive && hasConnected) {
ActiveGreen
} else if (isActive) {
DisconnectedRed
} else if (connection.color != 0) {
Color(connection.color)
} else {
MaterialTheme.colorScheme.primary
}
Box(
Row(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(circleColor),
contentAlignment = Alignment.Center
.fillMaxWidth()
.padding(start = 14.dp, top = 12.dp, bottom = 12.dp, end = 12.dp),
verticalAlignment = Alignment.Top
) {
Text(
text = (connection.nickname.ifBlank { connection.name })
.take(1)
.uppercase(),
color = Color.White,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = connection.nickname.ifBlank { connection.name },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
if (isActive && sessionInfo != null) {
// Live timer — always shown for active sessions
val context = LocalContext.current
val is24h = android.text.format.DateFormat.is24HourFormat(context)
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) {
while (true) {
delay(15_000)
now = System.currentTimeMillis()
// Info section
Column(modifier = Modifier.weight(1f)) {
// Name row with status dot
Row(verticalAlignment = Alignment.CenterVertically) {
if (isActive && hasConnected) {
Box(
modifier = Modifier
.size(6.dp)
.clip(CircleShape)
.background(activeColor.copy(alpha = pulseAlpha))
)
} else if (isActive) {
Box(
modifier = Modifier
.size(6.dp)
.clip(CircleShape)
.background(DisconnectedRed)
)
} else {
Box(
modifier = Modifier
.size(6.dp)
.border(1.dp, PrimaryTeal.copy(alpha = 0.4f), CircleShape)
)
}
}
// Terminal sessions line
if (sessionInfo.terminalCount > 0) {
SessionTypeLine(
count = sessionInfo.terminalCount,
color = ActiveGreen,
startTime = sessionInfo.terminalEarliestStart,
now = now,
is24h = is24h
Spacer(Modifier.width(8.dp))
Text(
text = (connection.nickname.ifBlank { connection.name }).uppercase(),
fontFamily = SpaceGrotesk,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = OnSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
letterSpacing = (-0.5).sp
)
}
// SFTP sessions line
if (sessionInfo.sftpCount > 0) {
if (sessionInfo.terminalCount > 0) Spacer(modifier = Modifier.height(2.dp))
SessionTypeLine(
count = sessionInfo.sftpCount,
color = AccentAmber,
startTime = sessionInfo.sftpEarliestStart,
now = now,
is24h = is24h
)
}
// Disconnected sessions line
if (sessionInfo.disconnectedCount > 0) {
if (sessionInfo.terminalCount > 0 || sessionInfo.sftpCount > 0) Spacer(modifier = Modifier.height(2.dp))
SessionTypeLabel(
count = sessionInfo.disconnectedCount,
color = DisconnectedRed,
label = stringResource(R.string.notification_count_disconnected, sessionInfo.disconnectedCount)
)
}
} else {
Spacer(Modifier.height(2.dp))
// user@host:port — monospace, teal when active
Text(
text = "${connection.username}@${connection.host}:${connection.port}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = if (isActive) PrimaryTeal.copy(alpha = 0.8f) else OnSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (sessionTrackingEnabled && connection.lastSessionStart > 0) {
val context = LocalContext.current
val is24h = android.text.format.DateFormat.is24HourFormat(context)
Text(
text = formatSessionRange(
Spacer(Modifier.height(2.dp))
if (isActive && sessionInfo != null) {
// Session badges
Row(
modifier = Modifier.padding(top = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
if (sessionInfo.terminalCount > 0) {
SessionBadge(
icon = Icons.Filled.Terminal,
text = stringResource(R.string.badge_ssh_count, sessionInfo.terminalCount),
color = ActiveGreen
)
}
if (sessionInfo.sftpCount > 0) {
SessionBadge(
icon = Icons.Filled.Folder,
text = stringResource(R.string.badge_sftp_count, sessionInfo.sftpCount),
color = AccentAmber
)
}
if (sessionInfo.disconnectedCount > 0) {
SessionBadge(
icon = null,
text = stringResource(R.string.notification_count_disconnected, sessionInfo.disconnectedCount),
color = DisconnectedRed
)
}
}
} else {
// Inactive: show last seen time
val timeText = if (sessionTrackingEnabled && connection.lastSessionStart > 0) {
val context = LocalContext.current
val is24h = resolveIs24h(timeFormat, context)
formatSessionRange(
connection.lastSessionStart,
connection.lastSessionEnd,
connection.lastSessionDurationMs,
is24h
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
} else if (connection.lastConnected > 0) {
Text(
text = formatRelativeTime(connection.lastConnected),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
)
} else if (connection.lastConnected > 0) {
formatRelativeTime(connection.lastConnected)
} else null
if (timeText != null) {
Text(
text = stringResource(R.string.last_seen_format, timeText),
fontSize = 10.sp,
color = OnSurfaceVariant,
fontWeight = FontWeight.Medium
)
}
}
}
Spacer(Modifier.width(8.dp))
// Right side: timer (active) or auth icon (inactive)
if (isActive && sessionInfo != null) {
Column(horizontalAlignment = Alignment.End) {
val context = LocalContext.current
val is24h = resolveIs24h(timeFormat, context)
var now by remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) {
while (true) {
delay(15_000)
now = System.currentTimeMillis()
}
}
val startTime = (sessionInfo.terminalEarliestStart.takeIf { it > 0 }
?: sessionInfo.sftpEarliestStart)
if (startTime > 0) {
Text(
text = "${formatTimeOnly(startTime, is24h)} \u00B7 ${formatDuration(now - startTime)}",
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = activeColor
)
}
}
} else {
Icon(
imageVector = if (connection.authType == "key") Icons.Filled.Key else Icons.Filled.Lock,
contentDescription = connection.authType,
tint = Color(0xFF7A8888),
modifier = Modifier.size(20.dp)
)
}
}
Spacer(modifier = Modifier.width(8.dp))
if (isActive) {
// Status dot — green if any connected, red if only disconnected
val dotColor = if (hasConnected) ActiveGreen else DisconnectedRed
// Flash overlay
if (flashAlpha.value > 0f) {
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(dotColor)
)
} else {
Icon(
imageVector = if (connection.authType == "key") {
Icons.Filled.Key
} else {
Icons.Filled.Lock
},
contentDescription = connection.authType,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
.matchParentSize()
.background(
PrimaryTeal.copy(alpha = flashAlpha.value),
cardShape
)
)
}
}
if (flashAlpha.value > 0f) {
Box(
modifier = Modifier
.matchParentSize()
.background(
Color(0xFF4FC3F7).copy(alpha = flashAlpha.value),
cardShape
)
)
}
} // Box
}
}
@Composable
private fun SessionTypeLine(
count: Int,
color: Color,
startTime: Long,
now: Long,
is24h: Boolean
private fun SessionBadge(
icon: ImageVector?,
text: String,
color: Color
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(18.dp)
.clip(CircleShape)
.background(color),
contentAlignment = Alignment.Center
) {
Text(
text = "$count",
color = Color.White,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold
Row(
modifier = Modifier
.background(color.copy(alpha = 0.1f), RoundedCornerShape(3.dp))
.border(1.dp, color.copy(alpha = 0.2f), RoundedCornerShape(3.dp))
.padding(horizontal = 8.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = color,
modifier = Modifier.size(12.dp)
)
}
Spacer(modifier = Modifier.width(6.dp))
val text = if (startTime > 0) {
val start = formatTimestamp(startTime, is24h)
val elapsed = formatDuration(now - startTime)
"$start · $elapsed"
} else {
""
}
if (text.isNotEmpty()) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = color.copy(alpha = 0.85f)
)
}
}
}
@Composable
private fun SessionTypeLabel(count: Int, color: Color, label: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(18.dp)
.clip(CircleShape)
.background(color),
contentAlignment = Alignment.Center
) {
Text(
text = "$count",
color = Color.White,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.width(6.dp))
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = color.copy(alpha = 0.85f)
text = text.uppercase(),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = color,
fontWeight = FontWeight.Bold
)
}
}
@Composable
private fun SessionTypeDot(count: Int, color: Color) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(18.dp)
.clip(CircleShape)
.background(color),
contentAlignment = Alignment.Center
) {
Text(
text = "$count",
color = Color.White,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold
)
}
}
}

View file

@ -4,6 +4,7 @@ package com.roundingmobile.sshworkbench.ui.screens
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -45,7 +46,9 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -73,6 +76,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
@ -119,6 +124,7 @@ fun ConnectionListScreen(
onPendingImportConsumed: () -> Unit = {}
) {
val connections by viewModel.connections.collectAsStateWithLifecycle()
val timeFormat by viewModel.timeFormat.collectAsStateWithLifecycle()
val quickConnectHistoryEntries by viewModel.quickConnectHistory.collectAsStateWithLifecycle()
var quickConnectText by remember { mutableStateOf("") }
var showQuickConnectHistory by remember { mutableStateOf(false) }
@ -152,12 +158,45 @@ fun ConnectionListScreen(
val isPicker = mode == ScreenMode.PICKER
Scaffold(
containerColor = Color(0xFF10141A),
topBar = {
TopAppBar(
title = { Text(if (isPicker) stringResource(R.string.new_session_label) else stringResource(R.string.app_name)) },
title = {
if (isPicker) {
Text(stringResource(R.string.new_session_label))
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Filled.Terminal,
contentDescription = null,
tint = Color(0xFF79DCDC),
modifier = Modifier.size(20.dp)
)
Text(
text = stringResource(R.string.app_name).uppercase().replace(" ", "_"),
fontFamily = FontFamily(Font(R.font.space_grotesk)),
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
color = Color(0xFF79DCDC),
letterSpacing = (-0.5).sp
)
}
}
},
modifier = Modifier.drawBehind {
drawLine(
color = Color(0xFF7A8888).copy(alpha = 0.2f),
start = Offset(0f, size.height),
end = Offset(size.width, size.height),
strokeWidth = 1.dp.toPx()
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface
containerColor = Color(0xFF10141A),
scrolledContainerColor = Color(0xFF10141A)
),
navigationIcon = {
if (isPicker) {
@ -170,17 +209,17 @@ fun ConnectionListScreen(
if (!isPicker) {
if (com.roundingmobile.sshworkbench.DevConfig.DEV_DEFAULTS) {
IconButton(onClick = onCopyLog) {
Icon(Icons.Filled.SaveAlt, contentDescription = stringResource(R.string.copy_log))
Icon(Icons.Filled.SaveAlt, contentDescription = stringResource(R.string.copy_log), tint = Color(0xFF7A8888))
}
IconButton(onClick = onClearLog) {
Icon(Icons.Filled.DeleteSweep, contentDescription = stringResource(R.string.clear_log))
Icon(Icons.Filled.DeleteSweep, contentDescription = stringResource(R.string.clear_log), tint = Color(0xFF7A8888))
}
}
IconButton(onClick = onNavigateToKeysVault) {
Icon(Icons.Filled.VpnKey, contentDescription = stringResource(R.string.keys_and_vault))
Icon(Icons.Filled.VpnKey, contentDescription = stringResource(R.string.keys_and_vault), tint = Color(0xFF6E7979))
}
IconButton(onClick = onNavigateToSettings) {
Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings))
Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings), tint = Color(0xFF6E7979))
}
}
}
@ -190,10 +229,13 @@ fun ConnectionListScreen(
if (!isPicker) {
FloatingActionButton(
onClick = { onNavigateToEdit(0L) },
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
containerColor = Color(0xFF262A31),
contentColor = Color(0xFF79DCDC),
shape = RoundedCornerShape(12.dp),
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
modifier = Modifier.border(1.dp, Color(0xFF79DCDC), RoundedCornerShape(12.dp))
) {
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.new_connection))
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.new_connection), modifier = Modifier.size(28.dp))
}
}
}
@ -227,13 +269,42 @@ fun ConnectionListScreen(
showQuickConnectHistory = false
}
},
placeholder = { Text(stringResource(R.string.quick_connect_hint)) },
textStyle = androidx.compose.ui.text.TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
color = Color(0xFF79DCDC)
),
placeholder = {
Text(
stringResource(R.string.quick_connect_hint),
fontFamily = FontFamily.Monospace,
color = Color(0xFF7A8888)
)
},
leadingIcon = {
Text(
"\$",
fontFamily = FontFamily.Monospace,
color = Color(0xFF7A8888),
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
},
singleLine = true,
trailingIcon = if (quickConnectText.isNotEmpty()) {
{ IconButton(onClick = { quickConnectText = ""; showQuickConnectHistory = false }) {
Icon(Icons.Filled.Close, contentDescription = null, modifier = Modifier.size(18.dp))
Icon(Icons.Filled.Close, contentDescription = null, modifier = Modifier.size(18.dp), tint = Color(0xFF6E7979))
} }
} else null,
} else {
{ Icon(Icons.Filled.Terminal, contentDescription = null, modifier = Modifier.size(16.dp), tint = Color(0xFF7A8888)) }
},
colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = Color(0xFF7A8888).copy(alpha = 0.2f),
focusedBorderColor = Color(0xFF79DCDC).copy(alpha = 0.4f),
cursorColor = Color(0xFF79DCDC),
unfocusedContainerColor = Color(0xFF181C22),
focusedContainerColor = Color(0xFF181C22)
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(
onGo = {
@ -256,7 +327,7 @@ fun ConnectionListScreen(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.small)
.background(Color(0xFF181C22), RoundedCornerShape(8.dp))
) {
filteredHistory.take(20).forEach { entry ->
Row(
@ -354,6 +425,7 @@ fun ConnectionListScreen(
connection = connection,
sessionInfo = connInfo,
sessionTrackingEnabled = proFeatures?.sessionTracking != false,
timeFormat = timeFormat,
highlight = pendingHighlightId == connection.id,
onHighlightDone = onHighlightConsumed,
onTap = {
@ -522,17 +594,19 @@ fun ConnectionListScreen(
}
)
// "New SFTP Session" — standalone, creates its own SSH connection
BottomSheetMenuItem(
icon = Icons.Filled.Folder,
label = stringResource(R.string.new_sftp_session),
onClick = {
scope.launch { sheetState.hide() }
sessionPickerConnection = null
onSftp(connection.id)
},
tint = Color(0xFFFFB020) // Amber
)
// "New SFTP Session" — standalone, creates its own SSH connection (SSH only)
if (connection.protocol == "ssh") {
BottomSheetMenuItem(
icon = Icons.Filled.Folder,
label = stringResource(R.string.new_sftp_session),
onClick = {
scope.launch { sheetState.hide() }
sessionPickerConnection = null
onSftp(connection.id)
},
tint = Color(0xFFFFB020) // Amber
)
}
if (activeCount >= 2) {
BottomSheetMenuItem(
@ -592,18 +666,21 @@ fun ConnectionListScreen(
val password = viewModel.getPassword(connection.id) ?: ""
onConnect(connection.id, connection.host, connection.port, connection.username, password, connection.keyId)
},
tint = if (hasActiveSessions) Color(0xFF4CAF50) else MaterialTheme.colorScheme.onSurface
)
BottomSheetMenuItem(
icon = Icons.Filled.Folder,
label = stringResource(R.string.new_sftp_session),
onClick = {
scope.launch { sheetState.hide() }
selectedConnection = null
onSftp(connection.id)
},
tint = Color(0xFFFFB020) // Amber
tint = if (hasActiveSessions) ActiveGreen else MaterialTheme.colorScheme.onSurface
)
// SFTP only for SSH connections
if (connection.protocol == "ssh") {
BottomSheetMenuItem(
icon = Icons.Filled.Folder,
label = stringResource(R.string.new_sftp_session),
onClick = {
scope.launch { sheetState.hide() }
selectedConnection = null
onSftp(connection.id)
},
tint = Color(0xFFFFB020) // Amber
)
}
if (hasActiveSessions) {
BottomSheetMenuItem(
icon = Icons.Filled.LinkOff,

View file

@ -1,10 +1,20 @@
package com.roundingmobile.sshworkbench.ui.screens
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -43,10 +53,21 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.roundingmobile.sshworkbench.R
@ -76,6 +97,7 @@ fun SettingsScreen(
val themeName by viewModel.themeName.collectAsStateWithLifecycle()
val scrollbackLines by viewModel.scrollbackLines.collectAsStateWithLifecycle()
val keepScreenOn by viewModel.keepScreenOn.collectAsStateWithLifecycle()
val timeFormat by viewModel.timeFormat.collectAsStateWithLifecycle()
val cursorSpeed by viewModel.cursorSpeed.collectAsStateWithLifecycle()
val cursorBlink by viewModel.cursorBlink.collectAsStateWithLifecycle()
val hapticFeedback by viewModel.hapticFeedback.collectAsStateWithLifecycle()
@ -190,6 +212,45 @@ fun SettingsScreen(
checked = keepScreenOn,
onCheckedChange = { viewModel.setKeepScreenOn(it) }
)
CardDivider()
// Time format
var timeFormatExpanded by remember { mutableStateOf(false) }
val timeFormatOptions = listOf(
"system" to stringResource(R.string.time_format_system),
"12h" to stringResource(R.string.time_format_12h),
"24h" to stringResource(R.string.time_format_24h)
)
val timeFormatLabel = timeFormatOptions.find { it.first == timeFormat }?.second
?: stringResource(R.string.time_format_system)
ExposedDropdownMenuBox(
expanded = timeFormatExpanded,
onExpandedChange = { timeFormatExpanded = it }
) {
OutlinedTextField(
value = timeFormatLabel,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.time_format)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = timeFormatExpanded) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
)
ExposedDropdownMenu(expanded = timeFormatExpanded, onDismissRequest = { timeFormatExpanded = false }) {
timeFormatOptions.forEach { (code, label) ->
DropdownMenuItem(
text = { Text(label) },
onClick = {
viewModel.setTimeFormat(code)
timeFormatExpanded = false
}
)
}
}
}
}
if (showThemePicker) {
@ -242,41 +303,41 @@ fun SettingsScreen(
CardDivider()
// Session navigation style
var navExpanded by remember { mutableStateOf(false) }
val navOptions = listOf(
"top_bar" to stringResource(R.string.session_nav_top_bar),
"drawer" to stringResource(R.string.session_nav_drawer)
)
val navLabel = navOptions.find { it.first == sessionNavStyle }?.second
?: stringResource(R.string.session_nav_top_bar)
ExposedDropdownMenuBox(
expanded = navExpanded,
onExpandedChange = { navExpanded = it }
var showNavPicker by remember { mutableStateOf(false) }
val navLabel = if (sessionNavStyle == "drawer") stringResource(R.string.session_nav_drawer)
else stringResource(R.string.session_nav_top_bar)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { showNavPicker = true }
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
OutlinedTextField(
value = navLabel,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.session_nav_style)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = navExpanded) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
)
ExposedDropdownMenu(expanded = navExpanded, onDismissRequest = { navExpanded = false }) {
navOptions.forEach { (code, label) ->
DropdownMenuItem(
text = { Text(label) },
onClick = {
viewModel.setSessionNavStyle(code)
navExpanded = false
}
)
}
Column {
Text(
stringResource(R.string.session_nav_style),
style = MaterialTheme.typography.bodyLarge
)
Text(
navLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (showNavPicker) {
SessionNavPickerDialog(
currentStyle = sessionNavStyle,
onSelect = { style ->
viewModel.setSessionNavStyle(style)
showNavPicker = false
},
onDismiss = { showNavPicker = false }
)
}
CardDivider()
// Cursor speed
@ -528,21 +589,24 @@ fun SettingsScreen(
}
}
SwitchRow(
SwitchRowWithHint(
label = stringResource(R.string.protect_full_app),
hint = stringResource(R.string.protect_full_app_hint),
checked = protectFullApp,
onCheckedChange = { nv -> protectBiometricGate(nv) { viewModel.setProtectScreenFullApp(it) } }
)
SwitchRow(
SwitchRowWithHint(
label = stringResource(R.string.protect_vault),
hint = stringResource(R.string.protect_vault_hint),
checked = protectFullApp || protectVault,
enabled = !protectFullApp,
onCheckedChange = { nv -> protectBiometricGate(nv) { viewModel.setProtectScreenVault(it) } }
)
SwitchRow(
SwitchRowWithHint(
label = stringResource(R.string.protect_terminal),
hint = stringResource(R.string.protect_terminal_hint),
checked = protectFullApp || protectTerminal,
enabled = !protectFullApp,
onCheckedChange = { nv -> protectBiometricGate(nv) { viewModel.setProtectScreenTerminal(it) } }
@ -706,12 +770,14 @@ private fun SwitchRowWithHint(
label: String,
hint: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
onCheckedChange: (Boolean) -> Unit,
enabled: Boolean = true
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onCheckedChange(!checked) }
.then(if (enabled) Modifier.clickable { onCheckedChange(!checked) } else Modifier)
.then(if (enabled) Modifier else Modifier.alpha(0.5f))
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
@ -725,6 +791,324 @@ private fun SwitchRowWithHint(
)
}
Spacer(Modifier.width(16.dp))
Switch(checked = checked, onCheckedChange = onCheckedChange)
Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
}
}
// ── Session Navigation Picker Dialog ──
@Composable
private fun SessionNavPickerDialog(
currentStyle: String,
onSelect: (String) -> Unit,
onDismiss: () -> Unit
) {
val teal = Color(0xFF5CC0C0)
val selectedBorder = teal
val unselectedBorder = MaterialTheme.colorScheme.outlineVariant
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.session_nav_style)) },
text = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Top bar option
Column(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.border(
width = if (currentStyle == "top_bar") 2.dp else 1.dp,
color = if (currentStyle == "top_bar") selectedBorder else unselectedBorder,
shape = RoundedCornerShape(12.dp)
)
.clickable { onSelect("top_bar") }
.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
NavPreviewTopBar(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(0.56f)
)
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.session_nav_top_bar),
style = MaterialTheme.typography.labelLarge,
color = if (currentStyle == "top_bar") teal
else MaterialTheme.colorScheme.onSurface
)
Text(
stringResource(R.string.session_nav_top_bar_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
// Drawer option
Column(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.border(
width = if (currentStyle == "drawer") 2.dp else 1.dp,
color = if (currentStyle == "drawer") selectedBorder else unselectedBorder,
shape = RoundedCornerShape(12.dp)
)
.clickable { onSelect("drawer") }
.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
NavPreviewDrawer(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(0.56f)
)
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.session_nav_drawer),
style = MaterialTheme.typography.labelLarge,
color = if (currentStyle == "drawer") teal
else MaterialTheme.colorScheme.onSurface
)
Text(
stringResource(R.string.session_nav_drawer_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.ok))
}
}
)
}
// ── Canvas previews ──
private val phoneBg = Color(0xFF111111)
private val phoneBorder = Color(0xFF444444)
private val terminalBg = Color(0xFF1A1A2E)
private val tabBarBg = Color(0xFF252530)
private val tabTeal = Color(0xFF5CC0C0)
private val tabAmber = Color(0xFFFFB020)
private val tabInactive = Color(0xFF444455)
private val termTextDim = Color(0xFF448844)
private val termTextBright = Color(0xFF66BB66)
private val cursorColor = Color(0xFF88DD88)
private val drawerBg = Color(0xFF1E1E2E)
private val drawerItemColor = Color(0xFF5CC0C0)
private val drawerItemDim = Color(0xFF3A6060)
private val kbBg = Color(0xFF1E1E20)
private val keyColor = Color(0xFF3A3A3E)
@Composable
private fun NavPreviewTopBar(modifier: Modifier = Modifier) {
val textMeasurer = rememberTextMeasurer()
Canvas(modifier = modifier) {
val w = size.width; val h = size.height
drawPhone(w, h) { iL, iT, iW, iH ->
// Tab bar (top)
val tabH = iH * 0.07f
drawRect(tabBarBg, Offset(iL, iT), Size(iW, tabH))
// Two tab chips
val chipW = iW * 0.35f; val chipH = tabH * 0.6f; val chipY = iT + (tabH - chipH) / 2
drawRoundRect(tabTeal.copy(alpha = 0.3f), Offset(iL + 4f, chipY), Size(chipW, chipH), CornerRadius(3f))
drawRoundRect(tabInactive.copy(alpha = 0.3f), Offset(iL + chipW + 8f, chipY), Size(chipW, chipH), CornerRadius(3f))
// Tiny tab label dots
drawCircle(tabTeal, 1.5f, Offset(iL + 4f + chipW * 0.5f, chipY + chipH * 0.5f))
drawCircle(tabAmber, 1.5f, Offset(iL + chipW + 8f + chipW * 0.5f, chipY + chipH * 0.5f))
// Terminal area
val termTop = iT + tabH
val kbH = iH * 0.25f
val termH = iH - tabH - kbH
drawRect(terminalBg, Offset(iL, termTop), Size(iW, termH))
// Terminal text lines
drawTerminalLines(iL, termTop, iW, termH, textMeasurer)
// Keyboard
drawRect(kbBg, Offset(iL, termTop + termH), Size(iW, kbH))
drawMiniKeys(iL + 2f, termTop + termH + 2f, iW - 4f, kbH - 4f)
}
}
}
@Composable
private fun NavPreviewDrawer(modifier: Modifier = Modifier) {
val textMeasurer = rememberTextMeasurer()
// Animate drawer sliding in and out
val transition = rememberInfiniteTransition(label = "drawer")
val drawerProgress by transition.animateFloat(
initialValue = 0f,
targetValue = 0f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 4000
0f at 0 using LinearEasing // closed
0f at 500 using LinearEasing // pause closed
1f at 1200 using LinearEasing // slide open
1f at 3000 using LinearEasing // hold open
0f at 3700 using LinearEasing // slide closed
0f at 4000 using LinearEasing // pause
},
repeatMode = RepeatMode.Restart
),
label = "drawerSlide"
)
Canvas(modifier = modifier) {
val w = size.width; val h = size.height
drawPhone(w, h) { iL, iT, iW, iH ->
// Thin top bar with hamburger + session label
val barH = iH * 0.07f
drawRect(tabBarBg, Offset(iL, iT), Size(iW, barH))
// Hamburger icon (3 lines) — highlights when drawer is opening
val burgL = iL + iW * 0.04f
val burgW = iW * 0.08f
val lineH = barH * 0.08f
val burgColor = if (drawerProgress > 0f && drawerProgress < 0.5f)
tabTeal else Color(0xFF888888)
for (i in 0 until 3) {
val ly = iT + barH * 0.3f + i * barH * 0.17f
drawRect(burgColor, Offset(burgL, ly), Size(burgW, lineH))
}
// Session label dot + bar
val labelDotX = burgL + burgW + iW * 0.04f
drawCircle(tabTeal, iH * 0.008f, Offset(labelDotX, iT + barH / 2))
drawRoundRect(
Color(0xFF666666), Offset(labelDotX + iW * 0.03f, iT + barH * 0.35f),
Size(iW * 0.3f, barH * 0.3f), CornerRadius(2f)
)
// Terminal area
val termTop = iT + barH
val kbH = iH * 0.25f
val termH = iH - barH - kbH
drawRect(terminalBg, Offset(iL, termTop), Size(iW, termH))
drawTerminalLines(iL, termTop, iW, termH, textMeasurer)
// Keyboard
drawRect(kbBg, Offset(iL, termTop + termH), Size(iW, kbH))
drawMiniKeys(iL + 2f, termTop + termH + 2f, iW - 4f, kbH - 4f)
// Drawer overlay (animated)
if (drawerProgress > 0f) {
val drawerW = iW * 0.6f
val drawerX = iL + drawerW * (drawerProgress - 1f)
// Scrim
drawRect(
Color.Black.copy(alpha = 0.4f * drawerProgress),
Offset(iL, iT), Size(iW, iH)
)
// Drawer panel
clipRect(iL, iT, iL + iW, iT + iH) {
drawRect(drawerBg, Offset(drawerX, iT), Size(drawerW, iH))
// Session items
val itemH = iH * 0.055f
val itemGap = iH * 0.02f
val itemStartY = iT + iH * 0.06f
val itemPadL = drawerX + drawerW * 0.1f
val dotR = iH * 0.012f
for (i in 0 until 3) {
val y = itemStartY + i * (itemH + itemGap)
val itemColor = if (i == 0) drawerItemColor else drawerItemDim
val dotColor = when (i) {
0 -> tabTeal
1 -> tabAmber
else -> tabTeal.copy(alpha = 0.5f)
}
drawCircle(dotColor, dotR, Offset(itemPadL + dotR, y + itemH / 2))
val barL = itemPadL + dotR * 3
val barW = drawerW * 0.55f
drawRoundRect(
itemColor.copy(alpha = 0.4f),
Offset(barL, y + itemH * 0.25f),
Size(barW, itemH * 0.5f),
CornerRadius(2f)
)
}
}
}
}
}
}
private inline fun DrawScope.drawPhone(
w: Float, h: Float,
content: DrawScope.(iL: Float, iT: Float, iW: Float, iH: Float) -> Unit
) {
val margin = 4f
val phoneW = w - margin * 2; val phoneH = h - margin * 2
val phoneL = margin; val phoneT = margin; val radius = 10f
drawRoundRect(phoneBg, Offset(phoneL, phoneT), Size(phoneW, phoneH), CornerRadius(radius))
drawRoundRect(phoneBorder, Offset(phoneL, phoneT), Size(phoneW, phoneH), CornerRadius(radius),
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 1.5f))
val m = 3f
content(phoneL + m, phoneT + m, phoneW - m * 2, phoneH - m * 2)
}
private fun DrawScope.drawTerminalLines(
l: Float, t: Float, w: Float, h: Float,
textMeasurer: androidx.compose.ui.text.TextMeasurer
) {
val lineH = h * 0.06f
val gap = h * 0.02f
val startY = t + h * 0.08f
val lines = listOf(
termTextDim to 0.7f,
termTextBright to 0.5f,
termTextDim to 0.85f,
termTextBright to 0.4f,
termTextDim to 0.6f,
termTextBright to 0.35f,
)
for ((i, pair) in lines.withIndex()) {
val y = startY + i * (lineH + gap)
if (y + lineH > t + h - gap) break
val (color, widthFraction) = pair
// Prompt marker
drawRect(termTextBright, Offset(l + w * 0.04f, y), Size(w * 0.06f, lineH * 0.7f))
// Text line
drawRect(color.copy(alpha = 0.5f), Offset(l + w * 0.12f, y), Size(w * widthFraction * 0.8f, lineH * 0.7f))
}
// Blinking cursor at last line
val lastLineIdx = lines.size.coerceAtMost(((t + h - gap - startY) / (lineH + gap)).toInt()) - 1
if (lastLineIdx >= 0) {
val curY = startY + lastLineIdx * (lineH + gap)
val lastWidth = lines[lastLineIdx].second
drawRect(cursorColor, Offset(l + w * 0.12f + w * lastWidth * 0.8f + 2f, curY), Size(w * 0.015f, lineH * 0.7f))
}
}
private fun DrawScope.drawMiniKeys(l: Float, t: Float, w: Float, h: Float) {
val rows = 4; val cols = 10; val gap = 1f
val kw = (w - gap * (cols + 1)) / cols
val kh = (h - gap * (rows + 1)) / rows
for (r in 0 until rows) for (c in 0 until cols) {
drawRoundRect(
keyColor,
Offset(l + gap + c * (kw + gap), t + gap + r * (kh + gap)),
Size(kw, kh),
CornerRadius(1.5f)
)
}
}

View file

@ -3,11 +3,22 @@ package com.roundingmobile.sshworkbench.ui.screens
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.roundingmobile.sshworkbench.R
import android.content.Context
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
/**
* Resolves the effective 24-hour flag based on the user's time format preference.
* @param timeFormat "system" (device default), "12h", or "24h"
*/
internal fun resolveIs24h(timeFormat: String, context: Context): Boolean = when (timeFormat) {
"24h" -> true
"12h" -> false
else -> android.text.format.DateFormat.is24HourFormat(context)
}
internal fun formatTimestamp(timestamp: Long, is24h: Boolean): String {
val cal = Calendar.getInstance().apply { timeInMillis = timestamp }
val timeFmt = if (is24h) "HH:mm" else "h:mm a"

View file

@ -25,6 +25,9 @@ class ConnectionListViewModel @Inject constructor(
val connections: StateFlow<List<SavedConnection>> = connectionDao.getAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val timeFormat: StateFlow<String> = terminalPrefs.timeFormat
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "system")
init {
viewModelScope.launch(Dispatchers.IO) {
DevConfig.initDevProfiles(connectionDao, credentialStore)

View file

@ -42,6 +42,9 @@ class SettingsViewModel @Inject constructor(
val keepScreenOn: StateFlow<Boolean> = terminalPreferences.keepScreenOn
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val timeFormat: StateFlow<String> = terminalPreferences.timeFormat
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "system")
val cursorSpeed: StateFlow<String> = terminalPreferences.cursorSpeed
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "normal")
@ -115,6 +118,11 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { terminalPreferences.setKeepScreenOn(enabled) }
}
fun setTimeFormat(format: String) {
FileLogger.log(TAG, "setTimeFormat: $format") // TRACE
viewModelScope.launch { terminalPreferences.setTimeFormat(format) }
}
fun setQuickBarVisible(visible: Boolean) {
FileLogger.log(TAG, "setQuickBarVisible: $visible") // TRACE
viewModelScope.launch { terminalPreferences.setQuickBarVisible(visible) }

Binary file not shown.

View file

@ -38,7 +38,7 @@
<string name="vault_save_locally">Guardar Vault localmente</string>
<string name="vault_import_local">Importar Vault local</string>
<string name="vault_local_description">Guarda una copia cifrada de todas tus conexiones, claves y snippets en este dispositivo. Esta copia solo se puede restaurar en este dispositivo.</string>
<string name="vault_local_password_too_short">La contraseña debe tener al menos 8 caracteres</string>
<string name="vault_local_password_too_short">La contraseña debe tener al menos 12 caracteres con mayúscula, minúscula, dígito y carácter especial</string>
<string name="vault_local_passwords_dont_match">Las contraseñas no coinciden</string>
<string name="vault_local_saved">Vault guardado correctamente</string>
<string name="vault_local_wrong_device">Este vault fue creado en otro dispositivo y no se puede importar aquí</string>
@ -116,6 +116,10 @@
<string name="font_size_value">%1$.0f sp</string>
<string name="scrollback_lines">Líneas de historial</string>
<string name="keep_screen_on">Mantener pantalla encendida</string>
<string name="time_format">Formato de hora</string>
<string name="time_format_system">Predeterminado del dispositivo</string>
<string name="time_format_12h">12 horas (2:30 PM)</string>
<string name="time_format_24h">24 horas (14:30)</string>
<string name="auto_reconnect">Reconexión automática</string>
<string name="notify_on_disconnect">Notificar al desconectar</string>
@ -128,8 +132,11 @@
<string name="show_quick_bar">Mostrar QuickBar</string>
<string name="security">Seguridad</string>
<string name="protect_full_app">Proteger toda la app</string>
<string name="protect_full_app_hint">Bloquea capturas, grabación de pantalla y la vista previa de la app en la lista de aplicaciones recientes — en toda la aplicación.</string>
<string name="protect_vault">Proteger vault</string>
<string name="protect_vault_hint">Bloquea capturas y la vista previa en aplicaciones recientes solo cuando estás en las pantallas de Claves y Vault.</string>
<string name="protect_terminal">Proteger terminal</string>
<string name="protect_terminal_hint">Bloquea capturas y la vista previa en aplicaciones recientes solo cuando hay una sesión de terminal visible.</string>
<string name="change_screen_protection">Cambiar protección de pantalla</string>
<string name="biometric_lock">Bloqueo biométrico</string>
<string name="about">Acerca de</string>
@ -364,6 +371,8 @@
<string name="session_nav_style">Navegación de sesiones</string>
<string name="session_nav_top_bar">Barra superior</string>
<string name="session_nav_drawer">Panel lateral</string>
<string name="session_nav_top_bar_desc">Pestañas en el borde superior</string>
<string name="session_nav_drawer_desc">Pulsa el botón de menú para abrir la lista de sesiones</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>
@ -464,6 +473,12 @@
<string name="notification_disconnected_sessions">%d sesión(es) desconectada(s)</string>
<string name="notification_no_active_sessions">Sin sesiones activas</string>
<string name="notification_count_disconnected">%d desconectada(s)</string>
<!-- Terminal-style connection list -->
<string name="last_seen_format">Última vez: %s</string>
<string name="last_seen_just_now">Ahora mismo</string>
<string name="badge_ssh_count">%d SSH</string>
<string name="badge_sftp_count">%d SFTP</string>
<string name="notification_count_port_fwd">%d reenvío(s) de puerto</string>
<string name="notification_ssh_disconnected">SSH desconectado</string>
<string name="notification_sessions_disconnected">%d sesiones desconectadas</string>

View file

@ -38,7 +38,7 @@
<string name="vault_save_locally">Spara Vault lokalt</string>
<string name="vault_import_local">Importera lokal Vault</string>
<string name="vault_local_description">Spara en krypterad säkerhetskopia av alla dina anslutningar, nycklar och snippets på denna enhet. Denna kopia kan bara återställas på denna enhet.</string>
<string name="vault_local_password_too_short">Lösenordet måste vara minst 8 tecken</string>
<string name="vault_local_password_too_short">Lösenordet måste vara minst 12 tecken med versal, gemen, siffra och specialtecken</string>
<string name="vault_local_passwords_dont_match">Lösenorden matchar inte</string>
<string name="vault_local_saved">Vault sparad</string>
<string name="vault_local_wrong_device">Denna vault skapades på en annan enhet och kan inte importeras här</string>
@ -116,6 +116,10 @@
<string name="font_size_value">%1$.0f sp</string>
<string name="scrollback_lines">Rullningshistorik</string>
<string name="keep_screen_on">Håll skärmen på</string>
<string name="time_format">Tidsformat</string>
<string name="time_format_system">Enhetens standard</string>
<string name="time_format_12h">12-timmar (2:30 EM)</string>
<string name="time_format_24h">24-timmar (14:30)</string>
<string name="auto_reconnect">Automatisk återanslutning</string>
<string name="notify_on_disconnect">Meddela vid frånkoppling</string>
@ -128,8 +132,11 @@
<string name="show_quick_bar">Visa QuickBar</string>
<string name="security">Säkerhet</string>
<string name="protect_full_app">Skydda hela appen</string>
<string name="protect_full_app_hint">Blockerar skärmdumpar, skärminspelning och appens förhandsvisning i listan över senaste appar — överallt i appen.</string>
<string name="protect_vault">Skydda vault</string>
<string name="protect_vault_hint">Blockerar skärmdumpar och förhandsvisningen i senaste appar endast när du är på skärmarna Nycklar och Vault.</string>
<string name="protect_terminal">Skydda terminal</string>
<string name="protect_terminal_hint">Blockerar skärmdumpar och förhandsvisningen i senaste appar endast när en terminalsession är synlig.</string>
<string name="change_screen_protection">Ändra skärmskydd</string>
<string name="biometric_lock">Biometriskt lås</string>
<string name="about">Om</string>
@ -363,6 +370,8 @@
<string name="session_nav_style">Sessionsnavigering</string>
<string name="session_nav_top_bar">Övre fält</string>
<string name="session_nav_drawer">Sidopanel</string>
<string name="session_nav_top_bar_desc">Flikar längs överkanten</string>
<string name="session_nav_drawer_desc">Tryck på menyknappen för att öppna sessionslistan</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>
@ -463,6 +472,12 @@
<string name="notification_disconnected_sessions">%d frånkopplad(e) session(er)</string>
<string name="notification_no_active_sessions">Inga aktiva sessioner</string>
<string name="notification_count_disconnected">%d frånkopplad(e)</string>
<!-- Terminal-style connection list -->
<string name="last_seen_format">Senast sedd: %s</string>
<string name="last_seen_just_now">Just nu</string>
<string name="badge_ssh_count">%d SSH</string>
<string name="badge_sftp_count">%d SFTP</string>
<string name="notification_count_port_fwd">%d portvidarebefordran</string>
<string name="notification_ssh_disconnected">SSH frånkopplad</string>
<string name="notification_sessions_disconnected">%d sessioner frånkopplade</string>

View file

@ -37,7 +37,7 @@
<string name="vault_save_locally">Save Vault Locally</string>
<string name="vault_import_local">Import Local Vault</string>
<string name="vault_local_description">Save an encrypted backup of all your connections, keys, and snippets to this device. This backup can only be restored on this device.</string>
<string name="vault_local_password_too_short">Password must be at least 8 characters</string>
<string name="vault_local_password_too_short">Password must be at least 12 characters with uppercase, lowercase, digit, and special character</string>
<string name="vault_local_passwords_dont_match">Passwords do not match</string>
<string name="vault_local_saved">Vault saved successfully</string>
<string name="vault_local_wrong_device">This vault was created on a different device and cannot be imported here</string>
@ -115,6 +115,10 @@
<string name="font_size_value">%1$.0f sp</string>
<string name="scrollback_lines">Scrollback Lines</string>
<string name="keep_screen_on">Keep Screen On</string>
<string name="time_format">Time Format</string>
<string name="time_format_system">Device default</string>
<string name="time_format_12h">12-hour (2:30 PM)</string>
<string name="time_format_24h">24-hour (14:30)</string>
<string name="auto_reconnect">Auto-Reconnect</string>
<string name="notify_on_disconnect">Notify on disconnect</string>
@ -129,8 +133,11 @@
<string name="show_quick_bar">Show QuickBar</string>
<string name="security">Security</string>
<string name="protect_full_app">Protect entire app</string>
<string name="protect_full_app_hint">Blocks screenshots, screen recording, and the app preview shown in the Recent Apps switcher — everywhere in the app.</string>
<string name="protect_vault">Protect vault</string>
<string name="protect_vault_hint">Blocks screenshots and the Recent Apps preview only while you are on the Keys &amp; Vault screens.</string>
<string name="protect_terminal">Protect terminal</string>
<string name="protect_terminal_hint">Blocks screenshots and the Recent Apps preview only while a terminal session is visible.</string>
<string name="change_screen_protection">Change screen protection</string>
<string name="biometric_lock">Biometric Lock</string>
<string name="about">About</string>
@ -384,6 +391,8 @@
<string name="session_nav_style">Session navigation</string>
<string name="session_nav_top_bar">Top bar</string>
<string name="session_nav_drawer">Drawer</string>
<string name="session_nav_top_bar_desc">Tabs along the top edge</string>
<string name="session_nav_drawer_desc">Tap menu button to open session list</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>
@ -484,6 +493,12 @@
<string name="notification_disconnected_sessions">%d disconnected session(s)</string>
<string name="notification_no_active_sessions">No active sessions</string>
<string name="notification_count_disconnected">%d disconnected</string>
<!-- Terminal-style connection list -->
<string name="last_seen_format">Last seen: %s</string>
<string name="last_seen_just_now">Just now</string>
<string name="badge_ssh_count">%d SSH</string>
<string name="badge_sftp_count">%d SFTP</string>
<string name="notification_count_port_fwd">%d port fwd</string>
<string name="notification_ssh_disconnected">SSH Disconnected</string>
<string name="notification_sessions_disconnected">%d sessions disconnected</string>

View file

@ -1,8 +1,23 @@
# SSH Workbench — TODO
> Updated: 2026-04-06
> Updated: 2026-04-10
> Status: Active development. Future ideas in `FUTURE.md`.
## Recently Completed (2026-04-10)
- ~~Time format setting~~ — Display → Time Format: Device default / 12-hour / 24-hour. `resolveIs24h()` helper in TimeFormatUtils, threaded through ConnectionListScreen → ConnectionItemCard
- ~~Protocol-aware accent colors~~ — connection cards use type-specific colors: green (SSH), amber (SFTP-only), violet (Telnet), sky blue (Local) for accent bar, dot, border, timer
- ~~Local shell proper session IDs~~`startLocalShell` now uses generated sessionId + savedConnectionId (was hardcoded 0L). Survives back-button, shows active on connection cards, supports multiple local shells
- ~~Local shell startup commands~~ — startup command execution + `lastOutputTimeNs` tracking added to local shell (was missing, caused 10s timeout)
- ~~SFTP updates lastConnected~~ — opening SFTP tab now updates lastConnected so connection sorts to top
- ~~SFTP option SSH-only~~ — "New SFTP Session" hidden for telnet and local connections
- ~~Quick connect bar colors~~`$` icon, placeholder text, trailing icon now use #7A8888 (matching lock/key icon color)
- ~~#3E4949 purged~~ — replaced all uses with #7A8888, too dark on terminal palette
- ~~Pulse animation tuned~~ — green dot breathes 100%→60% over 2s (was 100%→30% over 1.2s)
- ~~Connection list terminal redesign~~ — Space Grotesk font, dark terminal palette (#10141A/#181C22), accent bars via drawWithContent, session badges (pill-style), no circle avatars, outline FAB, "$" quick-connect prefix, SSH_WORKBENCH branded title bar
- ~~Auth cancel stops retries~~ — pressing Cancel on password dialog immediately stops auth (no more 3x retry loop), session closes cleanly
- ~~SFTP back button cd .. removed~~ — system back no longer navigates up folders in SFTP browser, uses breadcrumb/".." entry instead
## Recently Completed (2026-04-05 — 2026-04-06)
- ~~Subscription model migration~~ — replaced free/pro build flavors with single APK + Google Play Billing (monthly/yearly/lifetime)