Show elapsed-time cursor on usage bars
This commit is contained in:
1 parent
84f978f16d
commit
cd0229bed6
3 files changed
+116
-30
No files matched your search
@@ -15,6 +15,8 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
@@ -159,13 +161,7 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
|||||||
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal
|
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal
|
||||||
// value, Compose skips the recomposition, and a "left" that only ticked when the quota moved
|
// value, Compose skips the recomposition, and a "left" that only ticked when the quota moved
|
||||||
// would sit at a stale figure for hours.
|
// would sit at a stale figure for hours.
|
||||||
var now by remember { mutableStateOf(OffsetDateTime.now()) }
|
val now = rememberUsageNow()
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
while (true) {
|
|
||||||
delay(REFRESH_MS)
|
|
||||||
now = OffsetDateTime.now()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing at all for a session that meters nothing: a row saying "unknown" there would report
|
// Nothing at all for a session that meters nothing: a row saying "unknown" there would report
|
||||||
// a problem about a machine somebody chose, on every screen, forever.
|
// a problem about a machine somebody chose, on every screen, forever.
|
||||||
@@ -195,14 +191,7 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
|||||||
if (window == null) {
|
if (window == null) {
|
||||||
UsageNote("Usage unknown -- no window duration was reported")
|
UsageNote("Usage unknown -- no window duration was reported")
|
||||||
} else {
|
} else {
|
||||||
LinearProgressIndicator(
|
UsageProgressIndicator(window, now, Modifier.weight(1f))
|
||||||
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
|
|
||||||
// The same step at the same percentages as the dialog's bars: this is the
|
|
||||||
// same measurement, and a reader who learned the colour there has to be
|
|
||||||
// able to read it here without checking which screen they are on.
|
|
||||||
color = quotaColor(window.percent),
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Text(
|
Text(
|
||||||
usageWindowLabel(window, now),
|
usageWindowLabel(window, now),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
@@ -215,6 +204,56 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A clock shared by each usage surface, advanced independently of changes to the quota. */
|
||||||
|
@Composable
|
||||||
|
internal fun rememberUsageNow(): OffsetDateTime {
|
||||||
|
var now by remember { mutableStateOf(OffsetDateTime.now()) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
delay(REFRESH_MS)
|
||||||
|
now = OffsetDateTime.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The quota fill with a white tick showing how far the current time window has progressed. */
|
||||||
|
@Composable
|
||||||
|
internal fun UsageProgressIndicator(
|
||||||
|
window: UsageWindow,
|
||||||
|
now: OffsetDateTime,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val elapsed = usageWindowElapsedFraction(window, now)
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
|
||||||
|
// The same step at the same percentages everywhere: this is the same measurement, and a
|
||||||
|
// reader who learned the colour on one surface should not have to relearn it on another.
|
||||||
|
color = quotaColor(window.percent),
|
||||||
|
modifier =
|
||||||
|
modifier.drawWithContent {
|
||||||
|
drawContent()
|
||||||
|
elapsed?.let { fraction ->
|
||||||
|
drawLine(
|
||||||
|
color = Color.White,
|
||||||
|
start = Offset(size.width * fraction, 0f),
|
||||||
|
end = Offset(size.width * fraction, size.height),
|
||||||
|
strokeWidth = 2.dp.toPx(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Elapsed time divided by the reported window duration, or null when either value is unknown. */
|
||||||
|
internal fun usageWindowElapsedFraction(window: UsageWindow, now: OffsetDateTime): Float? {
|
||||||
|
val durationMinutes = window.durationMinutes?.takeIf { it > 0 } ?: return null
|
||||||
|
val end = windowEnd(window.resetsAt, now) as? WindowEnd.Ends ?: return null
|
||||||
|
val remainingMinutes =
|
||||||
|
end.until.seconds.toDouble() / 60.0 + end.until.nano.toDouble() / 60_000_000_000.0
|
||||||
|
return (1.0 - remainingMinutes / durationMinutes).coerceIn(0.0, 1.0).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
/** Anything this row says instead of drawing a bar, so all of them look the same. */
|
/** Anything this row says instead of drawing a bar, so all of them look the same. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun UsageNote(text: String) {
|
private fun UsageNote(text: String) {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -41,6 +40,7 @@ fun UsageDialog(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var signingIn by remember { mutableStateOf(false) }
|
var signingIn by remember { mutableStateOf(false) }
|
||||||
|
val now = rememberUsageNow()
|
||||||
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the gaps
|
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the gaps
|
||||||
// between its title, content and buttons at sizes meant for a sentence of prose and a decision;
|
// between its title, content and buttons at sizes meant for a sentence of prose and a decision;
|
||||||
// this is a dense read-out, and those gaps left a band of empty dialog above Close that was
|
// this is a dense read-out, and those gaps left a band of empty dialog above Close that was
|
||||||
@@ -88,7 +88,7 @@ fun UsageDialog(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
UsageBody(state, onSignIn = { signingIn = true })
|
UsageBody(state, now, onSignIn = { signingIn = true })
|
||||||
}
|
}
|
||||||
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
|
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
|
||||||
Text("Close")
|
Text("Close")
|
||||||
@@ -113,7 +113,11 @@ fun UsageDialog(
|
|||||||
|
|
||||||
/** What came back, or why nothing did. Split out so the dialog above reads as its own shape. */
|
/** What came back, or why nothing did. Split out so the dialog above reads as its own shape. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun UsageBody(state: LoadState<List<UsageSnapshot>>, onSignIn: () -> Unit) {
|
private fun UsageBody(
|
||||||
|
state: LoadState<List<UsageSnapshot>>,
|
||||||
|
now: OffsetDateTime,
|
||||||
|
onSignIn: () -> Unit,
|
||||||
|
) {
|
||||||
Column {
|
Column {
|
||||||
when (val current = state) {
|
when (val current = state) {
|
||||||
is LoadState.Loading -> CircularProgressIndicator()
|
is LoadState.Loading -> CircularProgressIndicator()
|
||||||
@@ -152,7 +156,7 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>, onSignIn: () -> Uni
|
|||||||
if (windowIndex > 0) {
|
if (windowIndex > 0) {
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
WindowBar(window)
|
WindowBar(window, now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,7 +223,7 @@ private fun SnapshotState(snapshot: UsageSnapshot, onSignIn: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WindowBar(window: UsageWindow) {
|
private fun WindowBar(window: UsageWindow, now: OffsetDateTime) {
|
||||||
Column {
|
Column {
|
||||||
Row(modifier = Modifier.fillMaxWidth()) {
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(
|
Text(
|
||||||
@@ -230,12 +234,8 @@ private fun WindowBar(window: UsageWindow) {
|
|||||||
Text("${window.percent.toInt()}%", style = MaterialTheme.typography.bodyMedium)
|
Text("${window.percent.toInt()}%", style = MaterialTheme.typography.bodyMedium)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
LinearProgressIndicator(
|
UsageProgressIndicator(window, now, Modifier.fillMaxWidth())
|
||||||
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
|
resetLine(window, now)?.let {
|
||||||
color = quotaColor(window.percent),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
resetLine(window)?.let {
|
|
||||||
Spacer(Modifier.height(2.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
Text(
|
Text(
|
||||||
it,
|
it,
|
||||||
@@ -254,8 +254,8 @@ private fun WindowBar(window: UsageWindow) {
|
|||||||
* failure appeared as an ISO string in a sentence written for a person. Both are named in
|
* failure appeared as an ISO string in a sentence written for a person. Both are named in
|
||||||
* [WindowEnd], and the session bar words them the same way.
|
* [WindowEnd], and the session bar words them the same way.
|
||||||
*/
|
*/
|
||||||
private fun resetLine(window: UsageWindow): String? =
|
private fun resetLine(window: UsageWindow, now: OffsetDateTime): String? =
|
||||||
when (val end = windowEnd(window.resetsAt, OffsetDateTime.now())) {
|
when (val end = windowEnd(window.resetsAt, now)) {
|
||||||
WindowEnd.NotRunning -> null
|
WindowEnd.NotRunning -> null
|
||||||
WindowEnd.Unreadable -> "reset time unreadable"
|
WindowEnd.Unreadable -> "reset time unreadable"
|
||||||
is WindowEnd.Ends ->
|
is WindowEnd.Ends ->
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
@@ -61,6 +62,52 @@ class SessionUsageTest {
|
|||||||
assertEquals(null, shortestUsageWindow(listOf(window("unknown", null))))
|
assertEquals(null, shortestUsageWindow(listOf(window("unknown", null))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the time cursor follows elapsed time through the window`() {
|
||||||
|
val now = OffsetDateTime.parse("2026-09-17T12:00:00Z")
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
0.4f,
|
||||||
|
usageWindowElapsedFraction(
|
||||||
|
window("5-hour window", 300, "2026-09-17T15:00:00Z"),
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the time cursor clamps at the window ends`() {
|
||||||
|
val now = OffsetDateTime.parse("2026-09-17T12:00:00Z")
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
0f,
|
||||||
|
usageWindowElapsedFraction(
|
||||||
|
window("5-hour window", 300, "2026-09-17T18:00:00Z"),
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
1f,
|
||||||
|
usageWindowElapsedFraction(
|
||||||
|
window("5-hour window", 300, "2026-09-17T11:00:00Z"),
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the time cursor is absent without a usable duration and reset time`() {
|
||||||
|
val now = OffsetDateTime.parse("2026-09-17T12:00:00Z")
|
||||||
|
|
||||||
|
assertEquals(null, usageWindowElapsedFraction(window("unknown", null), now))
|
||||||
|
assertEquals(null, usageWindowElapsedFraction(window("not running", 300), now))
|
||||||
|
assertEquals(
|
||||||
|
null,
|
||||||
|
usageWindowElapsedFraction(window("unreadable", 300, "not a timestamp"), now),
|
||||||
|
)
|
||||||
|
assertEquals(null, usageWindowElapsedFraction(window("zero", 0), now))
|
||||||
|
}
|
||||||
|
|
||||||
private fun snapshot(
|
private fun snapshot(
|
||||||
machine: String,
|
machine: String,
|
||||||
provider: String,
|
provider: String,
|
||||||
@@ -78,13 +125,13 @@ class SessionUsageTest {
|
|||||||
windows = emptyList(),
|
windows = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun window(label: String, durationMinutes: Long?) =
|
private fun window(label: String, durationMinutes: Long?, resetsAt: String? = null) =
|
||||||
UsageWindow(
|
UsageWindow(
|
||||||
kind = "test",
|
kind = "test",
|
||||||
label = label,
|
label = label,
|
||||||
percent = 12.0,
|
percent = 12.0,
|
||||||
durationMinutes = durationMinutes,
|
durationMinutes = durationMinutes,
|
||||||
resetsAt = null,
|
resetsAt = resetsAt,
|
||||||
active = false,
|
active = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user