From d5cc10475debbc4ae30a735501bf1657529d5bcf Mon Sep 17 00:00:00 2001 From: WayfinderAK Date: Thu, 9 Jul 2026 16:26:08 -0800 Subject: [PATCH] Polish dashboard ordering and density --- .../com/wayfinderak/ntpcheck/ui/NtpScreen.kt | 194 +++++++++--------- .../wayfinderak/ntpcheck/ui/NtpViewModel.kt | 26 +-- 2 files changed, 100 insertions(+), 120 deletions(-) diff --git a/app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpScreen.kt b/app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpScreen.kt index 13ae525..3a58d1a 100644 --- a/app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpScreen.kt +++ b/app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpScreen.kt @@ -8,11 +8,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Button @@ -44,66 +43,98 @@ fun NtpApp(viewModel: NtpViewModel) { MaterialTheme { Surface(modifier = Modifier.fillMaxSize()) { LazyColumn( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .statusBarsPadding() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - item { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Column { - Text("NTP Check", style = MaterialTheme.typography.headlineMedium) - Text("Local-first NTP comparison dashboard") - } - Button(onClick = viewModel::testAll) { Text("Test all") } - } - } - item { Profiles(state, viewModel) } - item { Controls(state, viewModel) } - item { - state.bestCandidate?.let { - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp)) { - Text("Best candidate", style = MaterialTheme.typography.titleMedium) - Text("${it.server.name} — health ${it.healthScore}/100") - } - } - } - } - item { AddServerForm(viewModel) } - item { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton( - onClick = { - scope.launch { - val csv = viewModel.exportCsv() - context.startActivity( - Intent.createChooser( - Intent(Intent.ACTION_SEND).apply { - type = "text/csv" - putExtra(Intent.EXTRA_SUBJECT, "NTP Check results") - putExtra(Intent.EXTRA_TEXT, csv) - }, - "Share NTP results", - ), - ) - } - }, - ) { Text("Export CSV") } - OutlinedButton(onClick = { context.startActivity(Intent(Settings.ACTION_DATE_SETTINGS)) }) { - Text("Date & Time settings") - } - } - } + item { Header() } + item { TopControls(state, viewModel) } + item { BestCandidate(state) } items(state.rows, key = { it.server.id }) { row -> ServerCard(row, viewModel) } + item { UtilityActions(context, scope, viewModel) } + item { Profiles(state, viewModel) } + item { AddServerForm(viewModel) } } } } } +@Composable +private fun Header() { + Column(Modifier.fillMaxWidth()) { + Text("NTP Check", style = MaterialTheme.typography.headlineLarge) + Text("NTP comparison dashboard", style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +private fun TopControls(state: NtpUiState, viewModel: NtpViewModel) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("Continuous test", style = MaterialTheme.typography.titleMedium) + Button(onClick = viewModel::testAll) { Text("One shot") } + } + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf(null to "Off", 1_000L to "1s", 5_000L to "5s", 10_000L to "10s", 30_000L to "30s").forEach { (interval, label) -> + FilterChip( + selected = state.continuousIntervalMs == interval, + onClick = { viewModel.setContinuous(interval) }, + label = { Text(label) }, + ) + } + } + } + } +} + +@Composable +private fun BestCandidate(state: NtpUiState) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(10.dp)) { + Text("Best candidate", style = MaterialTheme.typography.titleMedium) + val best = state.bestCandidate + if (best == null) { + Text("Run a test to choose a candidate", style = MaterialTheme.typography.bodyMedium) + } else { + Text("${best.server.name} — health ${best.healthScore}/100", style = MaterialTheme.typography.bodyMedium) + } + } + } +} + +@Composable +private fun UtilityActions(context: android.content.Context, scope: kotlinx.coroutines.CoroutineScope, viewModel: NtpViewModel) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = { + scope.launch { + val csv = viewModel.exportCsv() + context.startActivity( + Intent.createChooser( + Intent(Intent.ACTION_SEND).apply { + type = "text/csv" + putExtra(Intent.EXTRA_SUBJECT, "NTP Check results") + putExtra(Intent.EXTRA_TEXT, csv) + }, + "Share NTP results", + ), + ) + } + }, + ) { Text("Export CSV") } + OutlinedButton(onClick = { context.startActivity(Intent(Settings.ACTION_DATE_SETTINGS)) }) { + Text("Date & Time settings") + } + } +} + @Composable private fun Profiles(state: NtpUiState, viewModel: NtpViewModel) { var profileName by remember { mutableStateOf("") } Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text("Profiles", style = MaterialTheme.typography.titleMedium) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { state.profiles.forEach { profile -> @@ -127,34 +158,6 @@ private fun Profiles(state: NtpUiState, viewModel: NtpViewModel) { } } -@Composable -private fun Controls(state: NtpUiState, viewModel: NtpViewModel) { - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Sort", style = MaterialTheme.typography.titleMedium) - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - SortMode.entries.forEach { mode -> - FilterChip( - selected = state.sortMode == mode, - onClick = { viewModel.setSortMode(mode) }, - label = { Text(mode.name.lowercase().replaceFirstChar { it.uppercase() }) }, - ) - } - } - Text("Continuous test", style = MaterialTheme.typography.titleMedium) - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - listOf(null to "Off", 1_000L to "1s", 5_000L to "5s", 10_000L to "10s", 30_000L to "30s").forEach { (interval, label) -> - FilterChip( - selected = state.continuousIntervalMs == interval, - onClick = { viewModel.setContinuous(interval) }, - label = { Text(label) }, - ) - } - } - } - } -} - @Composable private fun AddServerForm(viewModel: NtpViewModel) { var name by remember { mutableStateOf("") } @@ -162,7 +165,7 @@ private fun AddServerForm(viewModel: NtpViewModel) { var port by remember { mutableStateOf("123") } var notes by remember { mutableStateOf("") } Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text("Add server", style = MaterialTheme.typography.titleMedium) OutlinedTextField(name, { name = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(host, { host = it }, label = { Text("Hostname/IP") }, modifier = Modifier.fillMaxWidth()) @@ -180,27 +183,22 @@ private fun AddServerForm(viewModel: NtpViewModel) { private fun ServerCard(row: ServerRow, viewModel: NtpViewModel) { val result = row.latest Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Column { - Text(row.server.name, style = MaterialTheme.typography.titleMedium) - Text("${row.server.host}:${row.server.port}") + Column(Modifier.weight(1f)) { + Text(row.server.name, style = MaterialTheme.typography.titleSmall) + Text("${row.server.host}:${row.server.port}", style = MaterialTheme.typography.bodySmall) } - Text("${row.healthScore}/100") + Text("${row.healthScore}/100", style = MaterialTheme.typography.labelLarge) } - Text("Status: ${result?.status ?: "untested"}") - result?.rttMs?.let { Text("RTT: ${it.formatMs()}") } - result?.offsetMs?.let { Text("Offset: ${it.formatMs()}") } - result?.stratum?.let { Text("Stratum: $it") } - result?.referenceId?.let { Text("Reference: $it") } - result?.rootDelayMs?.let { Text("Root delay: ${it.formatMs()}") } - result?.rootDispersionMs?.let { Text("Root dispersion: ${it.formatMs()}") } - result?.leapStatus?.let { Text("Leap: $it") } - result?.precision?.let { Text("Precision: $it") } - result?.warning?.let { Text("Warning: $it", color = MaterialTheme.colorScheme.error) } - row.comparisonWarning?.let { Text("Comparison: $it", color = MaterialTheme.colorScheme.error) } - if (row.server.notes.isNotBlank()) Text("Notes: ${row.server.notes}") - Spacer(Modifier.height(4.dp)) + Text("${result?.status ?: "untested"} RTT ${result?.rttMs?.formatMs() ?: "--"} Offset ${result?.offsetMs?.formatMs() ?: "--"}", style = MaterialTheme.typography.bodySmall) + Text("Stratum ${result?.stratum ?: "--"} Ref ${result?.referenceId ?: "--"} Leap ${result?.leapStatus ?: "--"}", style = MaterialTheme.typography.bodySmall) + result?.rootDispersionMs?.let { dispersion -> + Text("Root delay ${result.rootDelayMs?.formatMs() ?: "--"} Dispersion ${dispersion.formatMs()} Precision ${result.precision ?: "--"}", style = MaterialTheme.typography.bodySmall) + } + result?.warning?.let { Text("Warning: $it", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } + row.comparisonWarning?.let { Text("Comparison: $it", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } + if (row.server.notes.isNotBlank()) Text("Notes: ${row.server.notes}", style = MaterialTheme.typography.bodySmall) TextButton(onClick = { viewModel.deleteServer(row.server) }) { Text("Delete") } } } diff --git a/app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpViewModel.kt b/app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpViewModel.kt index 0a9f411..e3f9478 100644 --- a/app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpViewModel.kt +++ b/app/src/main/java/com/wayfinderak/ntpcheck/ui/NtpViewModel.kt @@ -27,12 +27,11 @@ import kotlin.math.abs class NtpViewModel(private val dao: NtpDao) : ViewModel() { private val client = NtpClient() private val activeProfileId = MutableStateFlow(1L) - private val sortMode = MutableStateFlow(SortMode.HEALTH) private val continuousIntervalMs = MutableStateFlow(null) private var continuousJob: Job? = null - private val controls = combine(activeProfileId, sortMode, continuousIntervalMs) { profileId, sort, interval -> - UiControls(profileId, sort, interval) + private val controls = combine(activeProfileId, continuousIntervalMs) { profileId, interval -> + UiControls(profileId, interval) } val uiState: StateFlow = combine( @@ -43,7 +42,7 @@ class NtpViewModel(private val dao: NtpDao) : ViewModel() { ) { profiles, servers, results, controls -> val latestByServer = results.associateBy { it.serverId } val medianOffset = results.mapNotNull { it.offsetMs }.sorted().medianOrNull() - val rows = servers.map { server -> + val rows = servers.sortedBy { it.name.lowercase(Locale.US) }.map { server -> val latest = latestByServer[server.id] ServerRow( server = server, @@ -51,12 +50,11 @@ class NtpViewModel(private val dao: NtpDao) : ViewModel() { healthScore = healthScore(latest, medianOffset), comparisonWarning = comparisonWarning(latest, medianOffset), ) - }.sortedWith(comparatorFor(controls.sortMode)) + } NtpUiState( profiles = profiles, activeProfileId = controls.profileId, rows = rows, - sortMode = controls.sortMode, continuousIntervalMs = controls.continuousIntervalMs, bestCandidate = rows.filter { it.latest?.status == "REACHABLE" }.maxByOrNull { it.healthScore }, ) @@ -66,10 +64,6 @@ class NtpViewModel(private val dao: NtpDao) : ViewModel() { viewModelScope.launch(Dispatchers.IO) { seedDefaults() } } - fun setSortMode(mode: SortMode) { - sortMode.value = mode - } - fun setProfile(id: Long) { activeProfileId.value = id } @@ -207,14 +201,6 @@ class NtpViewModel(private val dao: NtpDao) : ViewModel() { return score.coerceIn(0, 100) } - private fun comparatorFor(sort: SortMode): Comparator = when (sort) { - SortMode.HEALTH -> compareByDescending { it.healthScore }.thenBy { it.server.name } - SortMode.LATENCY -> compareBy { it.latest?.rttMs ?: Double.MAX_VALUE }.thenBy { it.server.name } - SortMode.OFFSET -> compareBy { abs(it.latest?.offsetMs ?: Double.MAX_VALUE) }.thenBy { it.server.name } - SortMode.STRATUM -> compareBy { it.latest?.stratum ?: Int.MAX_VALUE }.thenBy { it.server.name } - SortMode.REACHABILITY -> compareByDescending { it.latest?.status == "REACHABLE" }.thenBy { it.server.name } - } - private fun List.medianOrNull(): Double? = when { isEmpty() -> null size % 2 == 1 -> this[size / 2] @@ -236,7 +222,6 @@ data class NtpUiState( val profiles: List = emptyList(), val activeProfileId: Long = 1, val rows: List = emptyList(), - val sortMode: SortMode = SortMode.HEALTH, val continuousIntervalMs: Long? = null, val bestCandidate: ServerRow? = null, ) @@ -250,10 +235,7 @@ data class ServerRow( private data class UiControls( val profileId: Long, - val sortMode: SortMode, val continuousIntervalMs: Long?, ) -enum class SortMode { HEALTH, LATENCY, OFFSET, STRATUM, REACHABILITY } - fun Double.formatMs(): String = String.format(Locale.US, "%.1f ms", this)