Polish dashboard ordering and density
All checks were successful
android-apk / debug-apk (push) Successful in 7m41s

This commit is contained in:
WayfinderAK 2026-07-09 16:26:08 -08:00
parent 1864ed3105
commit d5cc10475d
No known key found for this signature in database
2 changed files with 100 additions and 120 deletions

View File

@ -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") }
}
}

View File

@ -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<Long?>(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<NtpUiState> = 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<ServerRow> = when (sort) {
SortMode.HEALTH -> compareByDescending<ServerRow> { it.healthScore }.thenBy { it.server.name }
SortMode.LATENCY -> compareBy<ServerRow> { it.latest?.rttMs ?: Double.MAX_VALUE }.thenBy { it.server.name }
SortMode.OFFSET -> compareBy<ServerRow> { abs(it.latest?.offsetMs ?: Double.MAX_VALUE) }.thenBy { it.server.name }
SortMode.STRATUM -> compareBy<ServerRow> { it.latest?.stratum ?: Int.MAX_VALUE }.thenBy { it.server.name }
SortMode.REACHABILITY -> compareByDescending<ServerRow> { it.latest?.status == "REACHABLE" }.thenBy { it.server.name }
}
private fun List<Double>.medianOrNull(): Double? = when {
isEmpty() -> null
size % 2 == 1 -> this[size / 2]
@ -236,7 +222,6 @@ data class NtpUiState(
val profiles: List<ProfileEntity> = emptyList(),
val activeProfileId: Long = 1,
val rows: List<ServerRow> = 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)