Skip to content

Commit ac3f158

Browse files
committed
Battery: Show live charge power in the hub and the session detail
The wattage was only ever visible in the dashboard card's reading line and as a recorded peak/average. The battery hub's electrical section now carries "Charge power" (the gated live figure, so a discharge draw can never read as charge power) and "Charger max" (the supply's advertised capability, read through only while something is connected). The charge session detail gains a live "Power now" row, but only while the viewed session is the open one the caller vouched for: the detail query is by id and unrestricted, so it can resolve a dangling open row from an earlier boot that the boot-scoped live query rejects. The activity therefore attributes the readout only when the viewed id equals the live session's id. Both surfaces distinguish a withheld figure from a missing one: "Not charging" where the battery positively reported it isn't taking charge, "Not reported" everywhere else.
1 parent 7681ae8 commit ac3f158

5 files changed

Lines changed: 322 additions & 29 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,14 @@ class MainActivity : ComponentActivity() {
430430
val statsDetail by statsViewModel.detailState.collectAsState()
431431
StatsSessionDetailScreen(
432432
state = statsDetail,
433+
// Live wattage only for the session that is actually charging now. The
434+
// detail query is by id and unrestricted, so it can resolve a dangling
435+
// open row from an earlier boot that the boot-scoped live query rejects
436+
// — attributing the current draw to it would be a fabrication.
437+
readout = state.batteryReadout.takeIf {
438+
val live = state.stats.live
439+
live != null && statsDetail?.summary?.id == live.id
440+
},
433441
onBack = {
434442
statsViewModel.closeSession()
435443
destination = detailOrigin

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@ import androidx.compose.ui.tooling.preview.Preview
2727
import androidx.compose.ui.unit.dp
2828
import eu.darken.amply.R
2929
import eu.darken.amply.battery.core.BatteryReadout
30+
import eu.darken.amply.battery.ui.BatteryEffect
3031
import eu.darken.amply.battery.ui.batteryHealthLabel
3132
import eu.darken.amply.battery.ui.batteryPlugLabel
3233
import eu.darken.amply.battery.ui.batteryStatusLabel
34+
import eu.darken.amply.battery.ui.chargePowerFallbackRes
3335
import eu.darken.amply.battery.ui.formatChargeCounter
3436
import eu.darken.amply.battery.ui.formatCurrent
3537
import eu.darken.amply.battery.ui.formatTemperature
@@ -38,6 +40,8 @@ import eu.darken.amply.common.compose.AmplyCard
3840
import eu.darken.amply.common.compose.AmplyCardDefaults
3941
import eu.darken.amply.common.compose.AmplyPreview
4042
import eu.darken.amply.common.compose.PreviewWrapper
43+
import eu.darken.amply.stats.core.StatsPowerCalculator
44+
import eu.darken.amply.stats.ui.StatsFormat
4145

4246
/**
4347
* The single battery/charging destination: the current or last charge and the full live readout, led
@@ -185,6 +189,21 @@ private fun ElectricalSection(readout: BatteryReadout) {
185189
stringResource(R.string.battery_detail_current),
186190
formatCurrent(readout.currentNowMicroamps) ?: notReported,
187191
)
192+
// Voltage × current above is a magnitude in either direction; this row is the gated charge
193+
// power, so a discharge draw can never be read here as charge power.
194+
DetailRow(
195+
stringResource(R.string.battery_detail_power),
196+
StatsFormat.power(StatsPowerCalculator.chargeMilliwatts(readout))
197+
?: stringResource(chargePowerFallbackRes(BatteryEffect.from(readout))),
198+
)
199+
// Advertised, not measured: what the connected supply claims. Nothing connected means nothing
200+
// to claim, so the extras are only read through while on a charger.
201+
DetailRow(
202+
stringResource(R.string.battery_detail_charger_max),
203+
readout.takeIf { it.onCharger }
204+
?.let { StatsFormat.power(StatsPowerCalculator.advertisedMaxMilliwatts(it)) }
205+
?: notReported,
206+
)
188207
DetailRow(
189208
stringResource(R.string.battery_detail_charge_counter),
190209
formatChargeCounter(readout.chargeCounterMicroampHours) ?: notReported,
@@ -251,6 +270,9 @@ private val previewCharging = BatteryReadout(
251270
currentNowMicroamps = 1_250_000,
252271
chargeCounterMicroampHours = 3_800_000,
253272
cycleCount = 142,
273+
// A 9 V / 2 A charger advertising itself while the battery measures ~5.2 W.
274+
maxChargingCurrentMicroamps = 2_000_000,
275+
maxChargingVoltageMicrovolts = 9_000_000,
254276
)
255277

256278
@AmplyPreview

app/src/main/java/eu/darken/amply/stats/ui/StatsSessionDetailScreen.kt

Lines changed: 72 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -26,25 +26,34 @@ import androidx.compose.ui.text.font.FontWeight
2626
import androidx.compose.ui.text.style.TextAlign
2727
import androidx.compose.ui.unit.dp
2828
import eu.darken.amply.R
29+
import eu.darken.amply.battery.core.BatteryReadout
30+
import eu.darken.amply.battery.ui.BatteryEffect
31+
import eu.darken.amply.battery.ui.chargePowerFallbackRes
2932
import eu.darken.amply.common.compose.AmplyCard
3033
import eu.darken.amply.common.compose.AmplyCardDefaults
3134
import eu.darken.amply.common.compose.AmplyPreview
3235
import eu.darken.amply.common.compose.PreviewWrapper
3336
import eu.darken.amply.stats.core.ChargeCurvePoint
3437
import eu.darken.amply.stats.core.ChargeSessionSummary
3538
import eu.darken.amply.stats.core.ChargingType
39+
import eu.darken.amply.stats.core.StatsPowerCalculator
3640
import eu.darken.amply.stats.core.StatsSealReason
3741

3842
/**
3943
* One session's charge curve (percent / power / temperature over time) plus its numeric summary.
4044
* State-hoisted; a null [state] shows a spinner while the ViewModel resolves the selection, and a
4145
* resolved state without a summary means the session no longer exists (discarded or cleared) — a
4246
* notice, never an eternal spinner. An open session's curve/summary keep updating live.
47+
*
48+
* [readout] is the *live* battery reading, and only the caller can say whether it belongs to the
49+
* session being viewed — pass null for any session that isn't the one currently charging, or the
50+
* current charge's wattage would be attributed to someone else's history.
4351
*/
4452
@OptIn(ExperimentalMaterial3Api::class)
4553
@Composable
4654
fun StatsSessionDetailScreen(
4755
state: StatsDetailState?,
56+
readout: BatteryReadout?,
4857
onBack: () -> Unit,
4958
) {
5059
Scaffold(
@@ -98,7 +107,7 @@ fun StatsSessionDetailScreen(
98107
verticalArrangement = Arrangement.spacedBy(12.dp),
99108
) {
100109
item { CurveCard(state.curve) }
101-
item { SummaryCard(summary) }
110+
item { SummaryCard(summary, readout) }
102111
if (summary.partial) {
103112
item {
104113
Text(
@@ -127,7 +136,7 @@ private fun CurveCard(curve: List<ChargeCurvePoint>) {
127136
}
128137

129138
@Composable
130-
private fun SummaryCard(summary: ChargeSessionSummary) {
139+
private fun SummaryCard(summary: ChargeSessionSummary, readout: BatteryReadout?) {
131140
val notReported = stringResource(R.string.battery_value_not_reported)
132141
AmplyCard(verticalArrangement = Arrangement.spacedBy(AmplyCardDefaults.ItemSpacing)) {
133142
DetailRow(stringResource(R.string.stats_detail_started), StatsFormat.dateTime(summary.startedAtWallMillis))
@@ -137,6 +146,15 @@ private fun SummaryCard(summary: ChargeSessionSummary) {
137146
DetailRow(stringResource(R.string.stats_detail_level), StatsFormat.percentRange(summary.startPercent, summary.endPercent))
138147
DetailRow(stringResource(R.string.stats_detail_duration), StatsFormat.duration(summary.durationMillis) ?: notReported)
139148
DetailRow(stringResource(R.string.stats_detail_charging_type), stringResource(chargingTypeLabel(summary.chargingType)))
149+
// Live, and only for the still-open session the caller vouched for: a closed session's numbers
150+
// are all history, and a "now" row beside them would belong to a different charge.
151+
if (readout != null && summary.endedAtWallMillis == null) {
152+
DetailRow(
153+
stringResource(R.string.stats_detail_power_now),
154+
StatsFormat.power(StatsPowerCalculator.chargeMilliwatts(readout))
155+
?: stringResource(chargePowerFallbackRes(BatteryEffect.from(readout))),
156+
)
157+
}
140158
DetailRow(stringResource(R.string.stats_detail_avg_power), StatsFormat.power(summary.avgPowerMilliwatts) ?: notReported)
141159
DetailRow(stringResource(R.string.stats_detail_peak_power), StatsFormat.power(summary.peakPowerMilliwatts) ?: notReported)
142160
DetailRow(
@@ -174,39 +192,64 @@ private fun DetailRow(label: String, value: String) {
174192
}
175193
}
176194

195+
private val previewCurve = (0..20).map { i ->
196+
ChargeCurvePoint(
197+
elapsedFromStartMillis = i * 180_000L,
198+
percent = (42 + i * 3).coerceAtMost(100),
199+
powerMilliwatts = (25_000 - i * 900).coerceAtLeast(1_500),
200+
temperatureTenthsC = 300 + i * 2,
201+
)
202+
}
203+
204+
private val previewSummary = ChargeSessionSummary(
205+
id = 1,
206+
startedAtWallMillis = System.currentTimeMillis() - 3_600_000,
207+
endedAtWallMillis = System.currentTimeMillis(),
208+
durationMillis = 3_600_000,
209+
startPercent = 42,
210+
endPercent = 100,
211+
chargingType = ChargingType.AC,
212+
avgPowerMilliwatts = 12_000,
213+
peakPowerMilliwatts = 25_000,
214+
minTemperatureTenthsC = 300,
215+
avgTemperatureTenthsC = 320,
216+
maxTemperatureTenthsC = 340,
217+
limitHit = false,
218+
partial = false,
219+
fullReachedAtWallMillis = System.currentTimeMillis() - 60_000,
220+
sealReason = StatsSealReason.UNPLUGGED,
221+
)
222+
177223
@AmplyPreview
178224
@Composable
179225
private fun StatsSessionDetailScreenPreview() = PreviewWrapper {
180-
val start = 0L
181-
val curve = (0..20).map { i ->
182-
ChargeCurvePoint(
183-
elapsedFromStartMillis = start + i * 180_000L,
184-
percent = (42 + i * 3).coerceAtMost(100),
185-
powerMilliwatts = (25_000 - i * 900).coerceAtLeast(1_500),
186-
temperatureTenthsC = 300 + i * 2,
187-
)
188-
}
226+
StatsSessionDetailScreen(
227+
state = StatsDetailState(summary = previewSummary, curve = previewCurve),
228+
readout = null,
229+
onBack = {},
230+
)
231+
}
232+
233+
@AmplyPreview
234+
@Composable
235+
private fun StatsSessionDetailScreenLivePreview() = PreviewWrapper {
236+
// The open session that is charging right now: only here does the summary carry a "Power now".
189237
StatsSessionDetailScreen(
190238
state = StatsDetailState(
191-
summary = ChargeSessionSummary(
192-
id = 1,
193-
startedAtWallMillis = System.currentTimeMillis() - 3_600_000,
194-
endedAtWallMillis = System.currentTimeMillis(),
195-
durationMillis = 3_600_000,
196-
startPercent = 42,
197-
endPercent = 100,
198-
chargingType = ChargingType.AC,
199-
avgPowerMilliwatts = 12_000,
200-
peakPowerMilliwatts = 25_000,
201-
minTemperatureTenthsC = 300,
202-
avgTemperatureTenthsC = 320,
203-
maxTemperatureTenthsC = 340,
204-
limitHit = false,
205-
partial = false,
206-
fullReachedAtWallMillis = System.currentTimeMillis() - 60_000,
207-
sealReason = StatsSealReason.UNPLUGGED,
239+
summary = previewSummary.copy(
240+
endedAtWallMillis = null,
241+
endPercent = 78,
242+
fullReachedAtWallMillis = null,
243+
sealReason = null,
208244
),
209-
curve = curve,
245+
curve = previewCurve,
246+
),
247+
readout = BatteryReadout(
248+
levelPercent = 78,
249+
status = android.os.BatteryManager.BATTERY_STATUS_CHARGING,
250+
plugged = android.os.BatteryManager.BATTERY_PLUGGED_AC,
251+
voltageMillivolts = 4_100,
252+
currentNowMicroamps = 2_050_000,
210253
),
211254
onBack = {},
212255
)
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package eu.darken.amply.main.ui.battery
2+
3+
import android.app.Application
4+
import android.os.BatteryManager
5+
import androidx.compose.ui.test.assertCountEquals
6+
import androidx.compose.ui.test.junit4.createComposeRule
7+
import androidx.compose.ui.test.onAllNodesWithText
8+
import androidx.compose.ui.test.onNodeWithText
9+
import androidx.test.core.app.ApplicationProvider
10+
import eu.darken.amply.R
11+
import eu.darken.amply.battery.core.BatteryReadout
12+
import org.junit.Rule
13+
import org.junit.Test
14+
import org.junit.runner.RunWith
15+
import org.robolectric.RobolectricTestRunner
16+
import org.robolectric.annotation.Config
17+
import org.robolectric.annotation.GraphicsMode
18+
19+
/**
20+
* The electrical section's two wattage rows. The tall qualifier renders the whole list, so a missing
21+
* row is a missing row rather than one scrolled out of the viewport.
22+
*/
23+
@RunWith(RobolectricTestRunner::class)
24+
@GraphicsMode(GraphicsMode.Mode.NATIVE)
25+
@Config(qualifiers = "+h2400dp")
26+
class BatteryHubScreenTest {
27+
@get:Rule
28+
val compose = createComposeRule()
29+
30+
private val context = ApplicationProvider.getApplicationContext<Application>()
31+
32+
private fun string(res: Int): String = context.getString(res)
33+
34+
// Every field reported, so a "Not reported" anywhere on the screen belongs to the row under test.
35+
private val charging = BatteryReadout(
36+
levelPercent = 82,
37+
status = BatteryManager.BATTERY_STATUS_CHARGING,
38+
plugged = BatteryManager.BATTERY_PLUGGED_AC,
39+
health = BatteryManager.BATTERY_HEALTH_GOOD,
40+
technology = "Li-ion",
41+
temperatureTenthsC = 314,
42+
voltageMillivolts = 4_000,
43+
currentNowMicroamps = 2_000_000,
44+
chargeCounterMicroampHours = 3_800_000,
45+
cycleCount = 142,
46+
maxChargingCurrentMicroamps = 2_000_000,
47+
maxChargingVoltageMicrovolts = 9_000_000,
48+
)
49+
50+
private fun render(readout: BatteryReadout?) {
51+
compose.setContent {
52+
BatteryHubScreen(
53+
readout = readout,
54+
captureEnabled = true,
55+
teaser = ChargeTeaserState.None,
56+
onBack = {},
57+
onOpenHistory = {},
58+
onEnableCapture = {},
59+
onOpenSession = {},
60+
)
61+
}
62+
}
63+
64+
@Test
65+
fun `the measured charge power and the advertised maximum are both shown`() {
66+
render(charging)
67+
compose.onNodeWithText(string(R.string.battery_detail_power)).assertExists()
68+
// 4.0 V x 2.0 A measured at the battery...
69+
compose.onNodeWithText("8.0 W").assertExists()
70+
// ...against the 9 V / 2 A the charger claims it could deliver.
71+
compose.onNodeWithText(string(R.string.battery_detail_charger_max)).assertExists()
72+
compose.onNodeWithText("18.0 W").assertExists()
73+
}
74+
75+
@Test
76+
fun `a limit hold says the battery is not charging, not that nothing was reported`() {
77+
render(charging.copy(status = BatteryManager.BATTERY_STATUS_NOT_CHARGING))
78+
// The status row spells this out as "Plugged in, not charging", so this is the power row.
79+
compose.onNodeWithText(string(R.string.battery_value_not_charging)).assertExists()
80+
}
81+
82+
@Test
83+
fun `unplugged there is no charge power and nothing advertised`() {
84+
render(
85+
charging.copy(
86+
status = BatteryManager.BATTERY_STATUS_DISCHARGING,
87+
plugged = 0,
88+
currentNowMicroamps = -500_000,
89+
),
90+
)
91+
compose.onNodeWithText(string(R.string.battery_value_not_charging)).assertExists()
92+
// Nothing is connected, so the advertised maximum is the screen's only "Not reported" — the
93+
// extras are not read through while off the charger even when the platform left them set.
94+
compose.onNodeWithText(string(R.string.battery_value_not_reported)).assertExists()
95+
}
96+
97+
@Test
98+
fun `a charger advertising nothing reports nothing rather than claiming zero`() {
99+
render(
100+
charging.copy(
101+
maxChargingCurrentMicroamps = null,
102+
maxChargingVoltageMicrovolts = null,
103+
),
104+
)
105+
compose.onNodeWithText("8.0 W").assertExists()
106+
compose.onNodeWithText(string(R.string.battery_value_not_reported)).assertExists()
107+
}
108+
109+
@Test
110+
fun `an unreadable battery never claims the battery is not charging`() {
111+
render(null)
112+
compose.onNodeWithText(string(R.string.battery_detail_power)).assertExists()
113+
compose.onAllNodesWithText(string(R.string.battery_value_not_charging)).assertCountEquals(0)
114+
}
115+
}

0 commit comments

Comments
 (0)