Polish dashboard ordering and density
All checks were successful
android-apk / debug-apk (push) Successful in 7m41s
All checks were successful
android-apk / debug-apk (push) Successful in 7m41s
This commit is contained in:
parent
1864ed3105
commit
d5cc10475d
@ -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,32 +43,69 @@ 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 { 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 { 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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 = {
|
||||
@ -92,18 +128,13 @@ fun NtpApp(viewModel: NtpViewModel) {
|
||||
Text("Date & Time settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
items(state.rows, key = { it.server.id }) { row -> ServerCard(row, viewModel) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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") }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user