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:
parent
7f4aa15830
commit
d591291c28
15 changed files with 904 additions and 325 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
BIN
app/src/main/res/font/space_grotesk.ttf
Normal file
BIN
app/src/main/res/font/space_grotesk.ttf
Normal file
Binary file not shown.
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
17
docs/TODO.md
17
docs/TODO.md
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue