Skip to content

Commit 46d163e

Browse files
committed
Dashboard: Give every charging-card value exactly one home
The card stated the same numbers up to six times: the level appeared in the reading line, twice in a "40% -> 78%" row, as the curve's end label and twice more in the chart legend. On a device held at its limit every span collapsed to the same figure, so the card read as an echo of itself. The middle row is gone. Elapsed time moves into the card header beside the chevron via a new typed headerStatus on AmplyNavigationCard - the header floats its trailing content and reports only the title's height, so this costs no vertical space. It is a String rather than a composable slot on purpose: the header sits inside the card's own tap target, so interactive content there would have to opt out of the surface tap. A reserved minimum title width plus a weighted status keep long values (duration never rolls over into days, so "123h 59m" is reachable) from starving either the title or the chevron. The level range survives in the chart legend, stated as the session's range rather than the plotted curve's span. The live curve is a bounded recent window, so its own span quietly narrows from "40->80%" to "79->80%" while the charge is still the same charge. percentRangeLabel defaults to the curve's span, so an explicit null means "no range" instead of falling back to the window - which is how the hub teaser, whose headline already carries the range, keeps from stating it twice. The compact chart drops its end labels, and no longer describes those endpoints to screen readers either: its hosts render the current values one line above, and the labels come from the last recorded sample, which lags by a recorder tick. Dropping them also frees the right gutter for the curve. It also refuses to draw without real variation. A device at its limit produces flat series, and a zero-range series self-normalizes to the canvas midpoint, so flat lines stacked into what looked like a plotted trend but was really the "no range" fallback. Variation must come from an adjacent non-null pair, because the path breaks at nulls: [40, null, 41] is two one-point segments and draws nothing. Both live surfaces now share one elapsed clock. Its first tick is aligned to the next whole session-minute (a fixed minute delay starting at 2m59s would hold the curve back to 3m59s), and it reads the clock on composition rather than trusting a caller's captured snapshot, so a card scrolling back into a lazy list doesn't re-enter holding a stale time. Store screenshots are regenerated. Their fixture now pins "now" so a live session has a believable age, and carries a real CC/CV curve - three linear series self-normalize to identical shapes and drew on top of each other.
1 parent 5424533 commit 46d163e

14 files changed

Lines changed: 561 additions & 108 deletions

File tree

app/src/debug/java/eu/darken/amply/screenshots/ScreenshotContent.kt

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,16 @@ private fun DashboardShot(state: DashboardUiState) = PreviewWrapper {
107107
onEmailSupport = {},
108108
onHelp = {},
109109
onDismissInterruption = {},
110+
// Fixture sessions start at elapsed-realtime 0, so pin "now" to give them a believable age:
111+
// otherwise every shot claims a charge that started this instant, and the charge curve — which
112+
// is withheld until a session has a few minutes of shape — never appears at all.
113+
nowElapsedRealtimeMillis = LIVE_SESSION_AGE_MILLIS,
110114
)
111115
}
112116

117+
/** 1h 15m into a charge: long enough for the curve to have earned its place on the card. */
118+
private const val LIVE_SESSION_AGE_MILLIS = 4_500_000L
119+
113120
private fun pixelDevice() = DeviceInfo("Google", "Pixel 8", 36, "preview")
114121

115122
private fun pixelPolicies() = listOf(
@@ -155,20 +162,33 @@ private fun charging() = holdingAtLimit().copy(
155162
currentNowMicroamps = 2_050_000,
156163
)
157164

165+
// A real charge, not three straight lines: the compact card self-normalizes every series, so linear
166+
// fixture data makes level and temperature resolve to the identical shape and draw on top of each
167+
// other — three legend entries, two visible curves. These follow an actual CC/CV charge instead:
168+
// level tapers as it approaches the limit, power falls away with it, temperature peaks mid-charge.
169+
private val LEVEL_CURVE = listOf(41, 45, 49, 53, 57, 61, 64, 67, 70, 73, 75, 77, 78, 79, 80)
170+
private val POWER_CURVE = listOf(
171+
19_000, 18_800, 18_400, 17_900, 17_100, 16_000, 14_500, 12_500,
172+
10_500, 8_500, 6_800, 5_200, 4_000, 3_200, 2_500,
173+
)
174+
private val TEMPERATURE_CURVE = listOf(
175+
300, 304, 308, 311, 313, 314, 315, 315, 314, 314, 313, 313, 312, 312, 311,
176+
)
177+
158178
private fun liveStats() = StatsDashboardState(
159179
enabled = true,
160180
live = StatsLiveSession(
161181
id = 1,
162182
startedAtWallMillis = 0L,
163183
startedElapsedRealtimeMillis = 0L,
164-
startPercent = 41,
184+
startPercent = LEVEL_CURVE.first(),
165185
partial = false,
166-
curve = (0..14).map { i ->
186+
curve = LEVEL_CURVE.indices.map { i ->
167187
ChargeCurvePoint(
168188
elapsedFromStartMillis = i * 300_000L,
169-
percent = (41 + i * 2).coerceAtMost(80),
170-
powerMilliwatts = (19_000 - i * 900).coerceAtLeast(2_500),
171-
temperatureTenthsC = 300 + i,
189+
percent = LEVEL_CURVE[i],
190+
powerMilliwatts = POWER_CURVE[i],
191+
temperatureTenthsC = TEMPERATURE_CURVE[i],
172192
)
173193
},
174194
),

app/src/main/java/eu/darken/amply/common/compose/AmplyCard.kt

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ object AmplyCardDefaults {
5858
* title↔body gap doesn't read as cramped once the trailing control is floated out of the row. */
5959
val HeaderContentSpacing = 4.dp
6060

61+
/** Width a header title always keeps, however wide the trailing content wants to be. */
62+
val MinHeaderTitleWidth = 96.dp
63+
6164
val ContentPaddingValues = PaddingValues(ContentPadding)
6265
}
6366

@@ -290,7 +293,12 @@ fun AmplyCardHeader(
290293
val gap = 8.dp.roundToPx()
291294
val bottomInset = AmplyCardDefaults.HeaderContentSpacing.roundToPx()
292295
val loose = constraints.copy(minWidth = 0, minHeight = 0)
293-
val trailingPlaceable = measurables[1].measure(loose)
296+
// Cap the trailing content so it can never starve the title: text trailing content (a status
297+
// line) would otherwise grow with its string — a multi-day "123h 59m" at a large font scale —
298+
// and squeeze the title into a vertical stack of wrapped characters.
299+
val trailingMaxWidth = (constraints.maxWidth - AmplyCardDefaults.MinHeaderTitleWidth.roundToPx() - gap)
300+
.coerceAtLeast(0)
301+
val trailingPlaceable = measurables[1].measure(loose.copy(maxWidth = trailingMaxWidth))
294302
val titleMaxWidth = (constraints.maxWidth - trailingPlaceable.width - gap).coerceAtLeast(0)
295303
val titlePlaceable = measurables[0].measure(loose.copy(maxWidth = titleMaxWidth))
296304
// Header reports the title height plus a little breathing room below; the (taller) trailing
@@ -312,6 +320,12 @@ fun AmplyCardHeader(
312320
* Navigation card: the whole card opens a destination ([onClick]) with an [onClickLabel] for
313321
* accessibility, a standard header, and a decorative RTL-aware trailing chevron.
314322
*
323+
* [headerStatus] adds a short status line beside the chevron. It is deliberately a **String**, not a
324+
* composable slot: the header sits inside the card's own click target, so anything interactive there
325+
* would have to opt out of the surface's tap — typing it as text makes that impossible rather than
326+
* merely discouraged. Being floated, it costs the card no vertical height (see [AmplyCardHeader]), so
327+
* it suits a value that would otherwise need a row of its own.
328+
*
315329
* The surface owns the primary tap. Content may add at most a small trailing text action for a
316330
* *secondary* destination or side effect (never a duplicate of [onClick]); anything richer — switches,
317331
* multiple buttons, a whole control row — belongs in [AmplyToggleCard] or a plain [AmplyCard]. A
@@ -327,6 +341,7 @@ fun AmplyNavigationCard(
327341
icon: ImageVector? = null,
328342
iconTint: Color = MaterialTheme.colorScheme.primary,
329343
tone: AmplyCardTone = AmplyCardTone.Default,
344+
headerStatus: String? = null,
330345
content: @Composable ColumnScope.() -> Unit,
331346
) {
332347
AmplyClickableCard(
@@ -341,11 +356,32 @@ fun AmplyNavigationCard(
341356
icon = icon,
342357
iconTint = iconTint,
343358
trailing = {
344-
Icon(
345-
Icons.AutoMirrored.Filled.KeyboardArrowRight,
346-
contentDescription = null,
347-
tint = MaterialTheme.colorScheme.onSurfaceVariant,
348-
)
359+
Row(
360+
horizontalArrangement = Arrangement.spacedBy(8.dp),
361+
verticalAlignment = Alignment.CenterVertically,
362+
) {
363+
if (headerStatus != null) {
364+
Text(
365+
headerStatus,
366+
style = MaterialTheme.typography.labelMedium,
367+
color = MaterialTheme.colorScheme.onSurfaceVariant,
368+
// Truncate rather than wrap: the header's height is the title's, so a
369+
// second status line would overflow it instead of growing the card.
370+
maxLines = 1,
371+
overflow = TextOverflow.Ellipsis,
372+
// Weighted so the chevron is measured first and keeps its width — an
373+
// unweighted text is measured against the whole row and can starve the
374+
// navigation affordance to nothing. fill = false keeps a short status
375+
// snug against the chevron instead of stretching to the cap.
376+
modifier = Modifier.weight(1f, fill = false),
377+
)
378+
}
379+
Icon(
380+
Icons.AutoMirrored.Filled.KeyboardArrowRight,
381+
contentDescription = null,
382+
tint = MaterialTheme.colorScheme.onSurfaceVariant,
383+
)
384+
}
349385
},
350386
)
351387
content()
@@ -422,6 +458,32 @@ private fun AmplyCardPreview() = PreviewWrapper {
422458
color = MaterialTheme.colorScheme.onSurfaceVariant,
423459
)
424460
}
461+
AmplyNavigationCard(
462+
onClick = {},
463+
onClickLabel = "Open details",
464+
title = "With header status",
465+
icon = Icons.AutoMirrored.Filled.KeyboardArrowRight,
466+
headerStatus = "2h 14m",
467+
) {
468+
Text(
469+
"Status floats beside the chevron and costs no height.",
470+
style = MaterialTheme.typography.bodyMedium,
471+
color = MaterialTheme.colorScheme.onSurfaceVariant,
472+
)
473+
}
474+
AmplyNavigationCard(
475+
onClick = {},
476+
onClickLabel = "Open details",
477+
title = "A title long enough to compete for the header row",
478+
icon = Icons.AutoMirrored.Filled.KeyboardArrowRight,
479+
headerStatus = "123h 59m",
480+
) {
481+
Text(
482+
"The title keeps its reserved minimum; the status truncates.",
483+
style = MaterialTheme.typography.bodyMedium,
484+
color = MaterialTheme.colorScheme.onSurfaceVariant,
485+
)
486+
}
425487
AmplyCard(tone = AmplyCardTone.PrimaryContainer) {
426488
Text("Primary-container tone (hero CTA).", style = MaterialTheme.typography.bodyMedium)
427489
}

app/src/main/java/eu/darken/amply/main/ui/battery/ChargeTeaser.kt

Lines changed: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,6 @@ import androidx.compose.material3.Icon
1212
import androidx.compose.material3.MaterialTheme
1313
import androidx.compose.material3.Text
1414
import androidx.compose.runtime.Composable
15-
import androidx.compose.runtime.LaunchedEffect
16-
import androidx.compose.runtime.getValue
17-
import androidx.compose.runtime.mutableLongStateOf
18-
import androidx.compose.runtime.remember
19-
import androidx.compose.runtime.setValue
2015
import androidx.compose.ui.Alignment
2116
import androidx.compose.ui.Modifier
2217
import androidx.compose.ui.res.stringResource
@@ -34,8 +29,8 @@ import eu.darken.amply.stats.core.StatsLiveSession
3429
import eu.darken.amply.stats.core.StatsSealReason
3530
import eu.darken.amply.stats.ui.StatsCurveChart
3631
import eu.darken.amply.stats.ui.StatsFormat
32+
import eu.darken.amply.stats.ui.rememberLiveElapsedMillis
3733
import eu.darken.amply.stats.ui.shouldShowLiveCurve
38-
import kotlinx.coroutines.delay
3934

4035
/**
4136
* The hub's compact current-or-last charge. Tapping opens that session's full detail; the states with
@@ -85,27 +80,26 @@ private fun LiveTeaser(
8580
onOpenSession: (Long) -> Unit,
8681
modifier: Modifier,
8782
) {
88-
// Battery readouts drive recomposition, but identical consecutive readouts are conflated upstream
89-
// — the minute tick keeps the elapsed text moving even when nothing else changes. Same backstop
90-
// the dashboard's charging card uses; StatsLiveSession carries a start, never a duration.
91-
var tickedNow by remember { mutableLongStateOf(nowElapsedRealtimeMillis) }
92-
LaunchedEffect(Unit) {
93-
while (true) {
94-
delay(LIVE_TICK_MILLIS)
95-
tickedNow = SystemClock.elapsedRealtime()
96-
}
97-
}
98-
val now = maxOf(tickedNow, nowElapsedRealtimeMillis)
99-
val elapsedMillis = (now - session.startedElapsedRealtimeMillis).coerceAtLeast(0)
83+
// Shared with the dashboard's charging card so the two clocks can't drift apart; StatsLiveSession
84+
// carries a start, never a duration.
85+
val elapsedMillis = rememberLiveElapsedMillis(session, nowElapsedRealtimeMillis)
10086

10187
TeaserCard(
10288
onClick = { onOpenSession(session.id) },
10389
headline = StatsFormat.percentRange(session.startPercent, currentPercent),
10490
detail = StatsFormat.duration(elapsedMillis),
10591
modifier = modifier,
10692
) {
107-
if (shouldShowLiveCurve(session.curve.size, elapsedMillis)) {
108-
StatsCurveChart(curve = session.curve, chartHeight = 84.dp, showAxes = false)
93+
if (shouldShowLiveCurve(session.curve, elapsedMillis)) {
94+
StatsCurveChart(
95+
curve = session.curve,
96+
chartHeight = 84.dp,
97+
showAxes = false,
98+
// No range in the legend: this card's headline already states it. Explicitly null
99+
// rather than omitted — the default would fall back to the plotted window's span,
100+
// which on a long session narrows to something the headline contradicts.
101+
percentRangeLabel = null,
102+
)
109103
}
110104
if (session.partial) {
111105
PartialNote()
@@ -184,9 +178,6 @@ private fun PartialNote() = Text(
184178
color = MaterialTheme.colorScheme.primary,
185179
)
186180

187-
/** Backstop cadence for the live elapsed text when battery readouts stop changing. */
188-
private const val LIVE_TICK_MILLIS = 60_000L
189-
190181
internal val previewCurve = (0..12).map { i ->
191182
ChargeCurvePoint(
192183
elapsedFromStartMillis = i * 300_000L,

app/src/main/java/eu/darken/amply/main/ui/dashboard/ChargingCard.kt

Lines changed: 28 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,13 @@ import android.os.SystemClock
55
import androidx.compose.foundation.layout.Arrangement
66
import androidx.compose.foundation.layout.Column
77
import androidx.compose.foundation.layout.ColumnScope
8-
import androidx.compose.foundation.layout.Row
9-
import androidx.compose.foundation.layout.fillMaxWidth
108
import androidx.compose.material.icons.Icons
119
import androidx.compose.material.icons.automirrored.filled.ShowChart
1210
import androidx.compose.material.icons.filled.BatteryChargingFull
1311
import androidx.compose.material3.MaterialTheme
1412
import androidx.compose.material3.Text
1513
import androidx.compose.material3.TextButton
1614
import androidx.compose.runtime.Composable
17-
import androidx.compose.runtime.LaunchedEffect
18-
import androidx.compose.runtime.getValue
19-
import androidx.compose.runtime.mutableLongStateOf
20-
import androidx.compose.runtime.remember
21-
import androidx.compose.runtime.setValue
2215
import androidx.compose.ui.Alignment
2316
import androidx.compose.ui.Modifier
2417
import androidx.compose.ui.platform.testTag
@@ -40,8 +33,8 @@ import eu.darken.amply.stats.core.StatsLiveSession
4033
import eu.darken.amply.stats.core.StatsSealReason
4134
import eu.darken.amply.stats.ui.StatsCurveChart
4235
import eu.darken.amply.stats.ui.StatsFormat
36+
import eu.darken.amply.stats.ui.rememberLiveElapsedMillis
4337
import eu.darken.amply.stats.ui.shouldShowLiveCurve
44-
import kotlinx.coroutines.delay
4538

4639
const val CHARGING_CARD_TEST_TAG = "dashboard.charging.card"
4740

@@ -56,6 +49,11 @@ const val CHARGING_CARD_TEST_TAG = "dashboard.charging.card"
5649
*
5750
* The card holds a fixed slot on the dashboard and always opens the battery hub — one card, one
5851
* destination. Retry is the sole in-card action (a repair, not navigation) and must not bubble.
52+
*
53+
* Each value appears exactly once. The header carries the session's elapsed time (floated beside the
54+
* chevron, so it costs no height), the reading line carries the current values, and the chart's legend
55+
* carries the session's level range — the card deliberately does not repeat any of them in a row of
56+
* its own.
5957
*/
6058
@Composable
6159
fun ChargingCard(
@@ -65,7 +63,7 @@ fun ChargingCard(
6563
onRetryCapture: () -> Unit,
6664
modifier: Modifier = Modifier,
6765
// Monotonic (never negative, immune to clock changes) and shares the curve's clock; re-evaluated
68-
// on each recomposition and backstopped by a minute tick while live (see LiveBody).
66+
// on each recomposition and backstopped by a minute tick while live (see rememberLiveElapsedMillis).
6967
nowElapsedRealtimeMillis: Long = SystemClock.elapsedRealtime(),
7068
) {
7169
// An absent readout renders every field as "Not reported" rather than hiding the line — the card
@@ -77,6 +75,13 @@ fun ChargingCard(
7775
// so the headline only says "Charging" when the platform positively reports it.
7876
val onCharger = battery.onCharger
7977
val charging = onCharger && battery.status == BatteryManager.BATTERY_STATUS_CHARGING
78+
// Elapsed belongs to the live session, so it is computed here (where the header needs it) and
79+
// handed down to the body, which gates the curve on the very same value.
80+
val elapsedMillis = if (presentation is ChargingCardPresentation.Live) {
81+
rememberLiveElapsedMillis(presentation.session, nowElapsedRealtimeMillis)
82+
} else {
83+
null
84+
}
8085
AmplyNavigationCard(
8186
onClick = onOpenHub,
8287
onClickLabel = stringResource(R.string.dashboard_charging_open_action),
@@ -89,6 +94,7 @@ fun ChargingCard(
8994
),
9095
icon = if (onCharger) Icons.Filled.BatteryChargingFull else Icons.AutoMirrored.Filled.ShowChart,
9196
modifier = modifier.testTag(CHARGING_CARD_TEST_TAG),
97+
headerStatus = elapsedMillis?.let { StatsFormat.duration(it) },
9298
) {
9399
Text(
94100
batteryNowLine(battery),
@@ -101,7 +107,7 @@ fun ChargingCard(
101107
ChargingCardPresentation.Unavailable -> BodyText(stringResource(R.string.dashboard_stats_unavailable))
102108
ChargingCardPresentation.Indeterminate ->
103109
BodyText(stringResource(R.string.dashboard_charging_indeterminate))
104-
is ChargingCardPresentation.Live -> LiveBody(presentation, battery, nowElapsedRealtimeMillis)
110+
is ChargingCardPresentation.Live -> LiveBody(presentation, battery, elapsedMillis ?: 0L)
105111
is ChargingCardPresentation.ConnectedWithoutSession -> ConnectedBody(presentation, onRetryCapture)
106112
is ChargingCardPresentation.Idle -> IdleBody(presentation)
107113
}
@@ -141,47 +147,22 @@ private fun BodyText(text: String) = Text(
141147
private fun ColumnScope.LiveBody(
142148
live: ChargingCardPresentation.Live,
143149
battery: BatteryReadout,
144-
nowElapsedRealtimeMillis: Long,
150+
elapsedMillis: Long,
145151
) {
146-
// Fresh battery readouts normally drive recomposition (~3s), but identical consecutive readouts
147-
// are conflated upstream — the minute tick keeps "charging for" moving even without new data.
148-
// maxOf picks whichever clock read is fresher; both are monotonic elapsed-realtime.
149-
var tickedNow by remember { mutableLongStateOf(nowElapsedRealtimeMillis) }
150-
LaunchedEffect(Unit) {
151-
while (true) {
152-
delay(LIVE_TICK_MILLIS)
153-
tickedNow = SystemClock.elapsedRealtime()
154-
}
155-
}
156-
val now = maxOf(tickedNow, nowElapsedRealtimeMillis)
157-
158-
val elapsedMillis = (now - live.session.startedElapsedRealtimeMillis).coerceAtLeast(0)
159-
val elapsed = StatsFormat.duration(elapsedMillis)
160-
Row(
161-
modifier = Modifier.fillMaxWidth(),
162-
horizontalArrangement = Arrangement.SpaceBetween,
163-
verticalAlignment = Alignment.CenterVertically,
164-
) {
165-
Text(
166-
StatsFormat.percentRange(live.session.startPercent, battery.levelPercent),
167-
style = MaterialTheme.typography.bodyMedium,
168-
color = MaterialTheme.colorScheme.onSurfaceVariant,
152+
if (shouldShowLiveCurve(live.session.curve, elapsedMillis)) {
153+
StatsCurveChart(
154+
curve = live.session.curve,
155+
chartHeight = 84.dp,
156+
showAxes = false,
157+
// The session's own range, not the plotted window's: the live curve is a bounded recent
158+
// window that ages the start out of a long session, so its span would quietly narrow from
159+
// "40→80%" to "79→80%" while the charge is still the same charge.
160+
percentRangeLabel = StatsFormat.percentSpan(live.session.startPercent, battery.levelPercent),
169161
)
170-
if (elapsed != null) {
171-
Text(
172-
elapsed,
173-
style = MaterialTheme.typography.bodyMedium,
174-
color = MaterialTheme.colorScheme.onSurfaceVariant,
175-
)
176-
}
177162
}
178163

179-
if (shouldShowLiveCurve(live.session.curve.size, elapsedMillis)) {
180-
StatsCurveChart(curve = live.session.curve, chartHeight = 84.dp, showAxes = false)
181-
}
182-
183-
// Small bottom-right caption noting a mid-charge start (so the start%/elapsed aren't read as a
184-
// full charge history).
164+
// Small bottom-right caption noting a mid-charge start (so the elapsed time and the level range
165+
// aren't read as a full charge history).
185166
if (live.session.partial) {
186167
Text(
187168
stringResource(R.string.dashboard_stats_live_since, StatsFormat.dateTime(live.session.startedAtWallMillis)),
@@ -234,9 +215,6 @@ private fun IdleBody(idle: ChargingCardPresentation.Idle) {
234215
}
235216
}
236217

237-
/** Backstop cadence for the live elapsed text when battery readouts stop changing. */
238-
private const val LIVE_TICK_MILLIS = 60_000L
239-
240218
private val previewCurve = (0..12).map { i ->
241219
ChargeCurvePoint(
242220
elapsedFromStartMillis = i * 300_000L,

0 commit comments

Comments
 (0)