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:
M57andM57 authored and GitHub committed 2026-08-09 15:35:42 +03:00
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),
)
}
},