fix(sessions): Messages stat card + slim prune button (#855)
* fix(sessions): show Messages stat + slim prune button The Active stat card always rendered 0: the mobile model declared 'val active: Int = 0', a key GET /api/sessions/stats never sends (backend returns total/active_store/archived/messages/by_source), and with ignoreUnknownKeys = true deserialization silently fell back to 0. Rework the stats row instead of just key-mapping: - Messages card replaces Active, reading the 'messages' key (db.message_count(), web_routers/sessions.py:523-550) - Prune becomes a slim 52dp icon-only card (contentDescription keeps it accessible) so the Messages card keeps width for large counts * feat(sessions): compact stat counts (100k / 1.23m) for Total & Messages formatCompactCount renders counts as max 3 digits + unit letter: 987 -> '987', 100987 -> '100k', 1234567 -> '1.23m', 100000000 -> '100m'. Applied to both the Total and Messages stat cards so large numbers fit the row. Covered by SessionsStatsFormatTest (5 test cases). * fix(sessions): label prune + empty-cleanup buttons, drop top select toggle - Prune card now sizes to its label (icon + 'Prune' text) instead of a 52dp icon-only square, staying compact via wrap-content width. - Empty-session cleanup becomes a labeled FilledTonalButton (icon + 'Delete empty sessions', count badge kept) instead of a bare icon. - Removed the top-bar select toggle (SelectAll icon); selection mode remains reachable via long-press > Select on any session row. - Dropped now-orphaned content_desc_enter/exit_selection strings (en/ko). * fix(sessions): prune card wrap-content width + shorter cleanup label The prune card dropped its fixed 52dp width but kept fillMaxSize on the inner Box — as an unweighted Row child that expands to the full row width, starving the weighted Total/Messages cards (reported broken on device). The Box now fills height only, so the Card wraps to its content (icon + 'Prune'). Empty-session cleanup button label shortened to 'Delete empty'. * fix(sessions): stack prune label under icon, cleanup button reads 'Empty' - Prune card: trash icon above the 'Prune' label (column layout), still wrap-content width so Total/Messages keep their space. - Empty-session cleanup button label shortened to 'Empty'. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
3b033a025b
commit
57dc4cdf5c
6 files changed
+108
-43
No files matched your search
@@ -27,7 +27,9 @@ data class SessionInfo(
|
||||
@Serializable
|
||||
data class SessionStatsResponse(
|
||||
val total: Int = 0,
|
||||
val active: Int = 0,
|
||||
// Backend GET /api/sessions/stats returns "messages" (total stored
|
||||
// messages) — the value the Sessions stats row shows.
|
||||
val messages: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
@@ -53,8 +54,8 @@ import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -531,34 +532,26 @@ fun SessionsScreen(
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
IconButton(onClick = { viewModel.toggleSelecting() }) {
|
||||
Icon(
|
||||
imageVector = if (state.isSelecting) Icons.Filled.Close else Icons.Filled.SelectAll,
|
||||
contentDescription =
|
||||
if (state.isSelecting) {
|
||||
stringResource(R.string.content_desc_exit_selection)
|
||||
} else {
|
||||
stringResource(R.string.content_desc_enter_selection)
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
// Empty-session cleanup (issue #787) — grey/disabled when 0.
|
||||
IconButton(
|
||||
onClick = { viewModel.requestEmptyCleanup() },
|
||||
enabled = state.emptyCount > 0,
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (state.emptyCount > 0) {
|
||||
Badge { Text("${state.emptyCount}") }
|
||||
}
|
||||
},
|
||||
) {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (state.emptyCount > 0) {
|
||||
Badge { Text("${state.emptyCount}") }
|
||||
}
|
||||
},
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.requestEmptyCleanup() },
|
||||
enabled = state.emptyCount > 0,
|
||||
contentPadding = PaddingValues(horizontal = spacing.md, vertical = 8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DeleteSweep,
|
||||
contentDescription = stringResource(R.string.sessions_empty_cleanup_desc),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.sessions_empty_cleanup_desc))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -695,20 +688,20 @@ fun SessionsScreen(
|
||||
) {
|
||||
StatCard(
|
||||
label = stringResource(R.string.sessions_stat_total),
|
||||
value = if (state.isLoadingStats) "…" else state.stats.total.toString(),
|
||||
value = if (state.isLoadingStats) "…" else formatCompactCount(state.stats.total),
|
||||
icon = Icons.Filled.History,
|
||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||
)
|
||||
StatCard(
|
||||
label = stringResource(R.string.sessions_stat_active),
|
||||
value = if (state.isLoadingStats) "…" else state.stats.active.toString(),
|
||||
icon = Icons.Filled.CheckCircle,
|
||||
accentColor = statusColors.success,
|
||||
label = stringResource(R.string.sessions_stat_messages),
|
||||
value = if (state.isLoadingStats) "…" else formatCompactCount(state.stats.messages),
|
||||
icon = Icons.Filled.Email,
|
||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||
)
|
||||
// Prune button card
|
||||
// Prune button — compact card sized to its label, so the
|
||||
// stat cards keep enough width for large counts.
|
||||
Card(
|
||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
@@ -716,17 +709,22 @@ fun SessionsScreen(
|
||||
onClick = { viewModel.showPruneDialog() },
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(spacing.md),
|
||||
// NOTE: fillMaxHeight only — never fillMaxSize here.
|
||||
// An unweighted Row child with fillMaxSize expands to the
|
||||
// full row width and starves the weighted stat cards.
|
||||
modifier = Modifier.fillMaxHeight().padding(horizontal = spacing.md),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DeleteSweep,
|
||||
contentDescription = null,
|
||||
tint = statusColors.warning,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
Text(
|
||||
text = stringResource(R.string.sessions_action_prune),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
|
||||
@@ -21,12 +21,37 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
|
||||
data class SessionStats(
|
||||
val total: Int = 0,
|
||||
val active: Int = 0,
|
||||
val messages: Int = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* Compact count for stat cards: max 3 digits + unit letter.
|
||||
* 987 → "987", 100987 → "100k", 1234567 → "1.23m", 100000000 → "100m".
|
||||
*/
|
||||
internal fun formatCompactCount(value: Int): String {
|
||||
if (value < 1_000) return value.toString()
|
||||
val (divisor, suffix) =
|
||||
when {
|
||||
value < 1_000_000 -> 1_000 to "k"
|
||||
value < 1_000_000_000 -> 1_000_000 to "m"
|
||||
else -> 1_000_000_000 to "b"
|
||||
}
|
||||
val scaled = value.toDouble() / divisor
|
||||
val digits =
|
||||
when {
|
||||
scaled >= 100 -> scaled.toInt().toString()
|
||||
scaled >= 10 -> String.format(Locale.US, "%.1f", scaled).trimZeroes()
|
||||
else -> String.format(Locale.US, "%.2f", scaled).trimZeroes()
|
||||
}
|
||||
return "$digits$suffix"
|
||||
}
|
||||
|
||||
private fun String.trimZeroes(): String = dropLastWhile { it == '0' }.trimEnd('.')
|
||||
|
||||
data class SessionsUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val isLoadingMore: Boolean = false,
|
||||
@@ -244,7 +269,7 @@ class SessionsViewModel :
|
||||
it.copy(
|
||||
isLoadingStats = false,
|
||||
stats =
|
||||
SessionStats(total = data.total, active = data.active),
|
||||
SessionStats(total = data.total, messages = data.messages),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -329,7 +329,7 @@
|
||||
<!-- Sessions Stats -->
|
||||
<string name="sessions_search_placeholder">세션 검색…</string>
|
||||
<string name="sessions_stat_total">전체</string>
|
||||
<string name="sessions_stat_active">활성</string>
|
||||
<string name="sessions_stat_messages">메시지</string>
|
||||
<string name="sessions_action_prune">정리</string>
|
||||
<string name="sessions_prune_title">오래된 세션 정리</string>
|
||||
<string name="sessions_prune_desc">지정된 일수보다 오래된 세션을 삭제합니다.</string>
|
||||
@@ -342,8 +342,6 @@
|
||||
<string name="sessions_action_select_all">전체 선택</string>
|
||||
<string name="sessions_action_deselect_all">선택 해제</string>
|
||||
<string name="sessions_action_delete_n">%1$d개 삭제</string>
|
||||
<string name="content_desc_enter_selection">세션 선택</string>
|
||||
<string name="content_desc_exit_selection">선택 종료</string>
|
||||
|
||||
<!-- Toolsets Screen -->
|
||||
<string name="toolsets_empty_title">툴셋이 없습니다</string>
|
||||
|
||||
@@ -506,13 +506,13 @@
|
||||
<string name="sessions_search_match_label">Match</string>
|
||||
<string name="sessions_search_model_label">Model</string>
|
||||
<string name="sessions_stat_total">Total</string>
|
||||
<string name="sessions_stat_active">Active</string>
|
||||
<string name="sessions_stat_messages">Messages</string>
|
||||
<string name="sessions_action_prune">Prune</string>
|
||||
<string name="sessions_prune_title">Prune Old Sessions</string>
|
||||
<string name="sessions_prune_desc">Delete sessions older than the specified number of days.</string>
|
||||
<string name="sessions_prune_days_label">Days threshold</string>
|
||||
<string name="sessions_prune_confirm">Prune</string>
|
||||
<string name="sessions_empty_cleanup_desc">Delete empty sessions</string>
|
||||
<string name="sessions_empty_cleanup_desc">Empty</string>
|
||||
<string name="sessions_empty_cleanup_title">Delete empty sessions?</string>
|
||||
<string name="sessions_empty_cleanup_message">Delete %1$d empty session(s). Active and archived sessions are kept.</string>
|
||||
<string name="sessions_empty_cleanup_confirm">Delete</string>
|
||||
@@ -527,8 +527,6 @@
|
||||
<string name="sessions_action_rename">Rename</string>
|
||||
<string name="sessions_rename_title">Rename Session</string>
|
||||
<string name="sessions_rename_hint">Session title</string>
|
||||
<string name="content_desc_enter_selection">Select sessions</string>
|
||||
<string name="content_desc_exit_selection">Exit selection</string>
|
||||
|
||||
<!-- Toolsets Screen -->
|
||||
<string name="toolsets_empty_title">No toolsets reported</string>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.m57.hermescontrol.ui.sessions
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class SessionsStatsFormatTest {
|
||||
@Test
|
||||
fun `under 1000 shows raw number`() {
|
||||
assertEquals("0", formatCompactCount(0))
|
||||
assertEquals("7", formatCompactCount(7))
|
||||
assertEquals("987", formatCompactCount(987))
|
||||
assertEquals("999", formatCompactCount(999))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `thousands compact to 3 digits plus k`() {
|
||||
assertEquals("1k", formatCompactCount(1_000))
|
||||
assertEquals("10k", formatCompactCount(10_000))
|
||||
assertEquals("100k", formatCompactCount(100_987))
|
||||
assertEquals("999k", formatCompactCount(999_999))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fractional thousands keep one or two digits`() {
|
||||
assertEquals("1.5k", formatCompactCount(1_500))
|
||||
assertEquals("1.05k", formatCompactCount(1_050))
|
||||
assertEquals("12.5k", formatCompactCount(12_500))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `millions compact to 3 digits plus m`() {
|
||||
assertEquals("1m", formatCompactCount(1_000_000))
|
||||
assertEquals("1.23m", formatCompactCount(1_234_567))
|
||||
assertEquals("100m", formatCompactCount(100_000_000))
|
||||
assertEquals("999m", formatCompactCount(999_999_999))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `billions compact to b`() {
|
||||
assertEquals("1b", formatCompactCount(1_000_000_000))
|
||||
assertEquals("2b", formatCompactCount(2_000_000_000))
|
||||
assertEquals("2.15b", formatCompactCount(Int.MAX_VALUE))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user