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.Column
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button import androidx.compose.material3.Button
@ -44,66 +43,98 @@ fun NtpApp(viewModel: NtpViewModel) {
MaterialTheme { MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
LazyColumn( LazyColumn(
modifier = Modifier.padding(16.dp), modifier = Modifier
verticalArrangement = Arrangement.spacedBy(12.dp), .statusBarsPadding()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
item { item { Header() }
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { item { TopControls(state, viewModel) }
Column { item { BestCandidate(state) }
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")
}
}
}
items(state.rows, key = { it.server.id }) { row -> ServerCard(row, viewModel) } 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 @Composable
private fun Profiles(state: NtpUiState, viewModel: NtpViewModel) { private fun Profiles(state: NtpUiState, viewModel: NtpViewModel) {
var profileName by remember { mutableStateOf("") } var profileName by remember { mutableStateOf("") }
Card(Modifier.fillMaxWidth()) { 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) Text("Profiles", style = MaterialTheme.typography.titleMedium)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
state.profiles.forEach { profile -> 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 @Composable
private fun AddServerForm(viewModel: NtpViewModel) { private fun AddServerForm(viewModel: NtpViewModel) {
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
@ -162,7 +165,7 @@ private fun AddServerForm(viewModel: NtpViewModel) {
var port by remember { mutableStateOf("123") } var port by remember { mutableStateOf("123") }
var notes by remember { mutableStateOf("") } var notes by remember { mutableStateOf("") }
Card(Modifier.fillMaxWidth()) { 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) Text("Add server", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(name, { name = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(name, { name = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(host, { host = it }, label = { Text("Hostname/IP") }, 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) { private fun ServerCard(row: ServerRow, viewModel: NtpViewModel) {
val result = row.latest val result = row.latest
Card(Modifier.fillMaxWidth()) { 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) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column { Column(Modifier.weight(1f)) {
Text(row.server.name, style = MaterialTheme.typography.titleMedium) Text(row.server.name, style = MaterialTheme.typography.titleSmall)
Text("${row.server.host}:${row.server.port}") 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"}") Text("${result?.status ?: "untested"} RTT ${result?.rttMs?.formatMs() ?: "--"} Offset ${result?.offsetMs?.formatMs() ?: "--"}", style = MaterialTheme.typography.bodySmall)
result?.rttMs?.let { Text("RTT: ${it.formatMs()}") } Text("Stratum ${result?.stratum ?: "--"} Ref ${result?.referenceId ?: "--"} Leap ${result?.leapStatus ?: "--"}", style = MaterialTheme.typography.bodySmall)
result?.offsetMs?.let { Text("Offset: ${it.formatMs()}") } result?.rootDispersionMs?.let { dispersion ->
result?.stratum?.let { Text("Stratum: $it") } Text("Root delay ${result.rootDelayMs?.formatMs() ?: "--"} Dispersion ${dispersion.formatMs()} Precision ${result.precision ?: "--"}", style = MaterialTheme.typography.bodySmall)
result?.referenceId?.let { Text("Reference: $it") } }
result?.rootDelayMs?.let { Text("Root delay: ${it.formatMs()}") } result?.warning?.let { Text("Warning: $it", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) }
result?.rootDispersionMs?.let { Text("Root dispersion: ${it.formatMs()}") } row.comparisonWarning?.let { Text("Comparison: $it", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) }
result?.leapStatus?.let { Text("Leap: $it") } if (row.server.notes.isNotBlank()) Text("Notes: ${row.server.notes}", style = MaterialTheme.typography.bodySmall)
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))
TextButton(onClick = { viewModel.deleteServer(row.server) }) { Text("Delete") } 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() { class NtpViewModel(private val dao: NtpDao) : ViewModel() {
private val client = NtpClient() private val client = NtpClient()
private val activeProfileId = MutableStateFlow(1L) private val activeProfileId = MutableStateFlow(1L)
private val sortMode = MutableStateFlow(SortMode.HEALTH)
private val continuousIntervalMs = MutableStateFlow<Long?>(null) private val continuousIntervalMs = MutableStateFlow<Long?>(null)
private var continuousJob: Job? = null private var continuousJob: Job? = null
private val controls = combine(activeProfileId, sortMode, continuousIntervalMs) { profileId, sort, interval -> private val controls = combine(activeProfileId, continuousIntervalMs) { profileId, interval ->
UiControls(profileId, sort, interval) UiControls(profileId, interval)
} }
val uiState: StateFlow<NtpUiState> = combine( val uiState: StateFlow<NtpUiState> = combine(
@ -43,7 +42,7 @@ class NtpViewModel(private val dao: NtpDao) : ViewModel() {
) { profiles, servers, results, controls -> ) { profiles, servers, results, controls ->
val latestByServer = results.associateBy { it.serverId } val latestByServer = results.associateBy { it.serverId }
val medianOffset = results.mapNotNull { it.offsetMs }.sorted().medianOrNull() 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] val latest = latestByServer[server.id]
ServerRow( ServerRow(
server = server, server = server,
@ -51,12 +50,11 @@ class NtpViewModel(private val dao: NtpDao) : ViewModel() {
healthScore = healthScore(latest, medianOffset), healthScore = healthScore(latest, medianOffset),
comparisonWarning = comparisonWarning(latest, medianOffset), comparisonWarning = comparisonWarning(latest, medianOffset),
) )
}.sortedWith(comparatorFor(controls.sortMode)) }
NtpUiState( NtpUiState(
profiles = profiles, profiles = profiles,
activeProfileId = controls.profileId, activeProfileId = controls.profileId,
rows = rows, rows = rows,
sortMode = controls.sortMode,
continuousIntervalMs = controls.continuousIntervalMs, continuousIntervalMs = controls.continuousIntervalMs,
bestCandidate = rows.filter { it.latest?.status == "REACHABLE" }.maxByOrNull { it.healthScore }, 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() } viewModelScope.launch(Dispatchers.IO) { seedDefaults() }
} }
fun setSortMode(mode: SortMode) {
sortMode.value = mode
}
fun setProfile(id: Long) { fun setProfile(id: Long) {
activeProfileId.value = id activeProfileId.value = id
} }
@ -207,14 +201,6 @@ class NtpViewModel(private val dao: NtpDao) : ViewModel() {
return score.coerceIn(0, 100) 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 { private fun List<Double>.medianOrNull(): Double? = when {
isEmpty() -> null isEmpty() -> null
size % 2 == 1 -> this[size / 2] size % 2 == 1 -> this[size / 2]
@ -236,7 +222,6 @@ data class NtpUiState(
val profiles: List<ProfileEntity> = emptyList(), val profiles: List<ProfileEntity> = emptyList(),
val activeProfileId: Long = 1, val activeProfileId: Long = 1,
val rows: List<ServerRow> = emptyList(), val rows: List<ServerRow> = emptyList(),
val sortMode: SortMode = SortMode.HEALTH,
val continuousIntervalMs: Long? = null, val continuousIntervalMs: Long? = null,
val bestCandidate: ServerRow? = null, val bestCandidate: ServerRow? = null,
) )
@ -250,10 +235,7 @@ data class ServerRow(
private data class UiControls( private data class UiControls(
val profileId: Long, val profileId: Long,
val sortMode: SortMode,
val continuousIntervalMs: Long?, val continuousIntervalMs: Long?,
) )
enum class SortMode { HEALTH, LATENCY, OFFSET, STRATUM, REACHABILITY }
fun Double.formatMs(): String = String.format(Locale.US, "%.1f ms", this) fun Double.formatMs(): String = String.format(Locale.US, "%.1f ms", this)