diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt index 24fd6f48..4dbc7bf2 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index d3643bf6..a8b5b415 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 44f58213..54e1fb43 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -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), ) } }, diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 11902b45..0b7ba3a3 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -329,7 +329,7 @@ 세션 검색… 전체 - 활성 + 메시지 정리 오래된 세션 정리 지정된 일수보다 오래된 세션을 삭제합니다. @@ -342,8 +342,6 @@ 전체 선택 선택 해제 %1$d개 삭제 - 세션 선택 - 선택 종료 툴셋이 없습니다 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0e38b0f5..9ec6ab22 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -506,13 +506,13 @@ Match Model Total - Active + Messages Prune Prune Old Sessions Delete sessions older than the specified number of days. Days threshold Prune - Delete empty sessions + Empty Delete empty sessions? Delete %1$d empty session(s). Active and archived sessions are kept. Delete @@ -527,8 +527,6 @@ Rename Rename Session Session title - Select sessions - Exit selection No toolsets reported diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsStatsFormatTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsStatsFormatTest.kt new file mode 100644 index 00000000..6c06f560 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsStatsFormatTest.kt @@ -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)) + } +}