Skip to content

Commit 01bafb0

Browse files
committed
Dashboard: Promote the stats card while on the charger
The battery-statistics card sat permanently at the tail of the dashboard, so a live charge was several scrolls below the fold. It now claims the second slot whenever external power is reported, and returns to the tail when unplugged. Promotion is plug-driven only: a device held at its limit reports BATTERY_STATUS_NOT_CHARGING while still connected, so charge status must not gate it. It is also independent of the card's content state — promo, loading and unavailable are promoted too, so the slot can't shift under the user while the stats DB answers. The promoted slot sits below the interrupted-session warning and the access setup guide, which are rarer and actionable. The single "on the charger" rule moves to BatteryReadout.onCharger, shared with the card's presentation mapping. The card keeps one lexical construction site; only its placement is conditional. Every dashboard item gains a stable LazyColumn key: index-keyed items would renumber when the stats card moves and lose their remembered state — plugging in mid-drag would have snapped the alarm slider back. Statistics also lose their settings entry: the dashboard card is now the only way in, so the capture switch lives next to the data it produces. While a session is live the card's own tap deep-links into that session, so the live state carries an explicit "History" action for the past-sessions list; it must not bubble to the card's navigation, which is asserted. With one entry point left, the stats screen's back paths (top-bar and system) both return to the dashboard, so statsOrigin is gone. AmplyNavigationCard's KDoc claimed it must hold no interactive controls, which its own tested "Retry" button already contradicted; it now describes the real contract (surface tap plus at most one small non-bubbling text action). Ordering assertions run at a tall Robolectric qualifier so every compared card is composed — otherwise a failure would mean "scrolled out", not "wrong order" — while above-the-fold assertions stay at the real screen height. A live plug/unplug transition test covers the move itself.
1 parent ae4bd4e commit 01bafb0

11 files changed

Lines changed: 379 additions & 139 deletions

File tree

app/src/main/java/eu/darken/amply/battery/core/BatteryReadout.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ data class BatteryReadout(
2424
val chargeCounterMicroampHours: Int? = null,
2525
val cycleCount: Int? = null,
2626
) {
27+
/**
28+
* External power is reported. A null (not reported) [plugged] collapses conservatively to false —
29+
* nothing may claim a charger it can't observe. This is the single "on the charger" rule; it is
30+
* deliberately independent of [status], because a device held at a charge limit reports
31+
* `BATTERY_STATUS_NOT_CHARGING` while still connected.
32+
*/
33+
val onCharger: Boolean get() = (plugged ?: 0) != 0
34+
2735
companion object {
2836
val UNKNOWN = BatteryReadout()
2937
}

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,8 +310,13 @@ fun AmplyCardHeader(
310310

311311
/**
312312
* Navigation card: the whole card opens a destination ([onClick]) with an [onClickLabel] for
313-
* accessibility, a standard header, and a decorative RTL-aware trailing chevron. Must contain no
314-
* independent interactive controls — the surface owns the single tap.
313+
* accessibility, a standard header, and a decorative RTL-aware trailing chevron.
314+
*
315+
* The surface owns the primary tap. Content may add at most a small trailing text action for a
316+
* *secondary* destination or side effect (never a duplicate of [onClick]); anything richer — switches,
317+
* multiple buttons, a whole control row — belongs in [AmplyToggleCard] or a plain [AmplyCard]. A
318+
* nested action must not bubble to the surface: assert both routes separately in tests, as
319+
* `StatsDashboardCard`'s "History" and "Retry" actions do.
315320
*/
316321
@Composable
317322
fun AmplyNavigationCard(

app/src/main/java/eu/darken/amply/main/ui/MainActivity.kt

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,6 @@ class MainActivity : ComponentActivity() {
106106
var destination by rememberSaveable { mutableStateOf(SettingsDestination.DASHBOARD) }
107107
// Where a back-out of the contribution wizard returns to (set on each entry).
108108
var wizardOrigin by rememberSaveable { mutableStateOf(SettingsDestination.DASHBOARD) }
109-
// Battery statistics is reachable from both the dashboard card and the settings hub;
110-
// remember which so back returns there.
111-
var statsOrigin by rememberSaveable { mutableStateOf(SettingsDestination.SETTINGS) }
112109
// Where the session-detail screen returns to: the stats list when opened from it, or
113110
// the dashboard when deep-linked from the live "on the charger" card.
114111
var detailOrigin by rememberSaveable { mutableStateOf(SettingsDestination.STATS) }
@@ -207,17 +204,18 @@ class MainActivity : ComponentActivity() {
207204
when (destination) {
208205
// The wizard clears its raw session and returns to whichever surface opened it.
209206
SettingsDestination.DIAGNOSTICS -> leaveWizard()
210-
// These are entered from the dashboard, not the settings hub.
207+
// These are entered from the dashboard, not the settings hub — the stats list
208+
// included, since the dashboard card is its only entry point.
211209
SettingsDestination.SETTINGS,
212210
SettingsDestination.RECONNECT_GESTURE,
213-
SettingsDestination.BATTERY_DETAIL -> destination = SettingsDestination.DASHBOARD
214-
// The session detail returns to the stats list; the list returns to wherever
215-
// it was opened from (dashboard card or settings hub).
211+
SettingsDestination.BATTERY_DETAIL,
212+
SettingsDestination.STATS -> destination = SettingsDestination.DASHBOARD
213+
// The session detail returns to the stats list, or to the dashboard when it was
214+
// deep-linked from the live card.
216215
SettingsDestination.STATS_SESSION_DETAIL -> {
217216
statsViewModel.closeSession()
218217
destination = detailOrigin
219218
}
220-
SettingsDestination.STATS -> destination = statsOrigin
221219
else -> destination = SettingsDestination.SETTINGS
222220
}
223221
}
@@ -256,10 +254,7 @@ class MainActivity : ComponentActivity() {
256254
onAlarmTargetChange = viewModel::setChargeAlarmTarget,
257255
onFixNotifications = viewModel::openNotificationSettings,
258256
onOpenBatteryDetail = { destination = SettingsDestination.BATTERY_DETAIL },
259-
onOpenStats = {
260-
statsOrigin = SettingsDestination.DASHBOARD
261-
destination = SettingsDestination.STATS
262-
},
257+
onOpenStats = { destination = SettingsDestination.STATS },
263258
onOpenLiveSession = { id ->
264259
// Deep-link straight to the in-progress session's detail; back returns
265260
// to the dashboard the card lives on.
@@ -289,10 +284,6 @@ class MainActivity : ComponentActivity() {
289284
SettingsDestination.SETTINGS -> SettingsScreen(
290285
onBack = { destination = SettingsDestination.DASHBOARD },
291286
onGeneral = { destination = SettingsDestination.GENERAL },
292-
onStats = {
293-
statsOrigin = SettingsDestination.SETTINGS
294-
destination = SettingsDestination.STATS
295-
},
296287
// Offered whenever this device is one we want contribution data for (unsupported/lab),
297288
// regardless of whether Shizuku is installed yet — the wizard nudges the install.
298289
showDiagnostics = state.charging.contributionWanted,
@@ -367,7 +358,9 @@ class MainActivity : ComponentActivity() {
367358
val statsState by statsViewModel.state.collectAsState()
368359
StatsScreen(
369360
state = statsState,
370-
onBack = { destination = statsOrigin },
361+
// Only the dashboard's stats card opens this screen, so back always
362+
// returns there.
363+
onBack = { destination = SettingsDestination.DASHBOARD },
371364
onCaptureEnabledChange = { enabled ->
372365
if (enabled) {
373366
runWithNotifications(NotificationAction.ENABLE_STATS)

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

Lines changed: 56 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.height
1414
import androidx.compose.foundation.layout.padding
1515
import androidx.compose.foundation.layout.size
1616
import androidx.compose.foundation.lazy.LazyColumn
17+
import androidx.compose.foundation.lazy.LazyListScope
1718
import androidx.compose.material.icons.Icons
1819
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
1920
import androidx.compose.material.icons.automirrored.filled.OpenInNew
@@ -155,24 +156,52 @@ fun DashboardScreen(
155156
// Derived once and shared by the status + full-charge cards so they can never disagree
156157
// about whether the one-time charge is actually in effect.
157158
val sessionPresentation = SessionPresentation.from(state.session, state.charging.observation)
158-
// The single stats card sits in one fixed slot (shared tail below); only its content
159-
// adapts. "On the charger" is decided by the raw battery readout, never the recorder's
160-
// DB row — see StatsCardPresentation for the truth rules.
159+
// There is one stats card; only its content adapts. "On the charger" is decided by the raw
160+
// battery readout, never the recorder's DB row — see StatsCardPresentation for the truth
161+
// rules.
161162
val statsPresentation = StatsCardPresentation.from(state.stats, state.batteryReadout)
163+
// Its slot is a separate, purely plug-driven decision: on the charger it is promoted to the
164+
// second card (below the interruption warning and the setup guide, which are rarer and
165+
// actionable), otherwise it stays in the shared tail. Deliberately independent of the
166+
// content state — a promo/loading/unavailable card is promoted too, so the slot never
167+
// changes underneath the user as the stats DB answers.
168+
val statsPromoted = state.batteryReadout?.onCharger == true
162169
val unsupported = state.charging.observation is ChargeObservation.Unsupported
163170
LazyColumn(
164171
modifier = Modifier.fillMaxSize(),
165172
contentPadding = PaddingValues(start = sidePadding, end = sidePadding, top = 8.dp, bottom = 8.dp),
166173
verticalArrangement = Arrangement.spacedBy(12.dp),
167174
) {
168-
item { StatusCard(state, sessionPresentation, onOpenBatteryDetail) }
175+
// Every card carries a stable key. Without one a LazyColumn identifies items by index, so
176+
// the stats card changing slot would renumber everything after it and throw away their
177+
// remembered state — e.g. the alarm card's in-flight slider drag would snap back if the
178+
// charger were plugged in mid-gesture. Keys are unique per render: the pairs that appear
179+
// in both the supported and unsupported branches are mutually exclusive.
180+
//
181+
// One construction site for the stats card — only its slot varies (see statsPromoted),
182+
// so the supported/unsupported branches below can't drift apart.
183+
val statsItem: LazyListScope.() -> Unit = {
184+
item(key = "dashboard.stats") {
185+
StatsDashboardCard(
186+
presentation = statsPresentation,
187+
onOpenStats = onOpenStats,
188+
onOpenLiveSession = onOpenLiveSession,
189+
onRetryCapture = onRetryCapture,
190+
)
191+
}
192+
}
193+
194+
item(key = "dashboard.hero") { StatusCard(state, sessionPresentation, onOpenBatteryDetail) }
169195

170196
if (unsupported) {
197+
// Promoted slot: no interruption card or setup guide exists on this branch, so on
198+
// the charger the stats card follows the hero directly.
199+
if (statsPromoted) statsItem()
171200
// Unsupported devices cannot use the Pixel policy/charge controls; showing them
172201
// greyed-out (and a "Pixel settings" action) only confuses non-Pixel users. Offer
173202
// a contribution path instead, and keep only the restore card if a session lingers.
174203
if (state.session != null) {
175-
item {
204+
item(key = "dashboard.fullcharge") {
176205
FullChargeCard(
177206
presentation = sessionPresentation,
178207
canControl = state.charging.controlEnabled &&
@@ -186,7 +215,7 @@ fun DashboardScreen(
186215
// after it was enabled), keep the card so the user can turn it — and its foreground
187216
// service — off. The card renders as disable-only when control isn't available.
188217
if (state.quickFullChargeEnabled) {
189-
item {
218+
item(key = "dashboard.reconnect") {
190219
QuickFullChargeCard(
191220
enabled = true,
192221
anyLevel = state.quickFullChargeAnyLevel,
@@ -198,7 +227,7 @@ fun DashboardScreen(
198227
}
199228
}
200229
// Point unsupported devices at their OEM's own charge-protection setting.
201-
item {
230+
item(key = "dashboard.oemguide") {
202231
OemGuideCard(
203232
manufacturer = state.charging.device.manufacturer
204233
.ifBlank { stringResource(R.string.dashboard_manufacturer_fallback) },
@@ -212,13 +241,13 @@ fun DashboardScreen(
212241
// otherwise — a warning about a restore Amply cannot even perform would confuse.
213242
val interruption = state.interruption
214243
if (interruption != null && state.charging.controlEnabled) {
215-
item { InterruptionCard(interruption, onDismissInterruption) }
244+
item(key = "dashboard.interruption") { InterruptionCard(interruption, onDismissInterruption) }
216245
}
217246
// Shizuku-only adapters (OnePlus/ColorOS) can't use WSS at all, so the WSS/ADB
218247
// setup guide would ask for an ineffective grant — the dedicated
219248
// "Shizuku required" banner below covers their setup instead.
220249
if (!state.charging.writeRequiresShizuku && state.charging.access?.direct?.ready != true) {
221-
item {
250+
item(key = "dashboard.setupguide") {
222251
AccessSetupGuide(
223252
state = state,
224253
adbCommand = adbCommand,
@@ -231,19 +260,23 @@ fun DashboardScreen(
231260
}
232261
}
233262

234-
item {
263+
// Promoted slot: below the interruption warning and the setup guide above, so a
264+
// restore that is still owed and an unfinished setup keep the top of the list.
265+
if (statsPromoted) statsItem()
266+
267+
item(key = "dashboard.fullcharge") {
235268
FullChargeCard(
236269
presentation = sessionPresentation,
237270
canControl = state.charging.canApply,
238271
onStart = onStartFull,
239272
onRestore = onRestore,
240273
)
241274
}
242-
item { PolicyCard(state, onApply, onNativeSettings) }
275+
item(key = "dashboard.policy") { PolicyCard(state, onApply, onNativeSettings) }
243276
// Hidden where the adapter lacks the gesture's hardware signal (non-Pixel) —
244277
// unless it is still switched on and needs a way to be turned off.
245278
if (state.charging.reconnectSupported || state.quickFullChargeEnabled) {
246-
item {
279+
item(key = "dashboard.reconnect") {
247280
QuickFullChargeCard(
248281
enabled = state.quickFullChargeEnabled,
249282
anyLevel = state.quickFullChargeAnyLevel,
@@ -255,9 +288,10 @@ fun DashboardScreen(
255288
}
256289
}
257290

258-
// Shared tail: the alarm and the single stats card render on every device class —
259-
// one lexical slot each, so the supported/unsupported branches can't drift apart.
260-
item {
291+
// Shared tail: the alarm renders on every device class, and the stats card joins it here
292+
// whenever it wasn't promoted above — one lexical slot each, so the supported and
293+
// unsupported branches can't drift apart.
294+
item(key = "dashboard.alarm") {
261295
ChargeAlarmCard(
262296
config = state.alarm,
263297
notificationsBlocked = state.notificationsBlocked,
@@ -266,18 +300,11 @@ fun DashboardScreen(
266300
onFixNotifications = onFixNotifications,
267301
)
268302
}
269-
item {
270-
StatsDashboardCard(
271-
presentation = statsPresentation,
272-
onOpenStats = onOpenStats,
273-
onOpenLiveSession = onOpenLiveSession,
274-
onRetryCapture = onRetryCapture,
275-
)
276-
}
303+
if (!statsPromoted) statsItem()
277304

278305
if (unsupported) {
279306
if (state.charging.contributionWanted) {
280-
item {
307+
item(key = "dashboard.unsupported") {
281308
UnsupportedDeviceCard(
282309
manufacturer = state.charging.device.manufacturer
283310
.ifBlank { stringResource(R.string.dashboard_manufacturer_fallback) },
@@ -302,7 +329,7 @@ fun DashboardScreen(
302329
quickAccess = state.quickAccess,
303330
)
304331
) {
305-
item {
332+
item(key = "dashboard.quickaccess") {
306333
QuickAccessCard(
307334
widgetAdded = state.quickAccess.widgetAdded,
308335
tileAdded = state.quickAccess.tileAdded,
@@ -320,7 +347,7 @@ fun DashboardScreen(
320347
// disabled until it is connected.
321348
state.charging.controlEnabled &&
322349
state.charging.writeRequiresShizuku &&
323-
access?.shizuku?.ready != true -> item {
350+
access?.shizuku?.ready != true -> item(key = "dashboard.shizukubanner") {
324351
ShizukuBanner(
325352
running = access?.shizuku?.available == true,
326353
requiredForControl = true,
@@ -332,7 +359,7 @@ fun DashboardScreen(
332359
// (computer) path: durable control is present but Shizuku isn't, so exact
333360
// readback/diagnostics are missing. Sync-readback adapters verify through
334361
// any backend, so the readback pitch would be wrong there.
335-
access?.direct?.ready == true && !access.canVerify && !state.charging.syncVerification -> item {
362+
access?.direct?.ready == true && !access.canVerify && !state.charging.syncVerification -> item(key = "dashboard.shizukubanner") {
336363
ShizukuBanner(
337364
running = access.shizuku.available,
338365
requiredForControl = false,
@@ -926,7 +953,8 @@ private fun DashboardScreenPreview() = PreviewWrapper {
926953
)
927954
}
928955

929-
// On the charger with capture enabled: the stats card in its fixed slot renders the live session.
956+
// On the charger with capture enabled: the stats card is promoted to the second slot and renders the
957+
// live session (the unplugged preview above shows the same card in its tail slot).
930958
@AmplyPreview
931959
@Composable
932960
private fun DashboardScreenLiveChargePreview() = PreviewWrapper {

0 commit comments

Comments
 (0)