Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 228 additions & 0 deletions app/src/debug/java/eu/darken/amply/screenshots/HubFixtures.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
// Battery-hub screenshot content. These composables render the hub's tile grid, the per-metric
// detail screen and the charge-time card from crafted fixtures, so the screenshotTest source set can
// capture them to PNGs on the JVM (no device). They live in the debug source set so they never ship
// in a release build, and each has an IDE @Preview for quick iteration. Engineering regression shots
// — they are NOT part of the Play Store screenshot flow (see ScreenshotContent.kt).
package eu.darken.amply.screenshots

import android.os.BatteryManager
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import eu.darken.amply.battery.core.BatteryReadout
import eu.darken.amply.common.compose.PreviewWrapper
import eu.darken.amply.main.ui.battery.BatteryHubScreen
import eu.darken.amply.main.ui.battery.BatteryMetric
import eu.darken.amply.main.ui.battery.BatteryMetricDetailScreen
import eu.darken.amply.main.ui.battery.BatteryMetricDetailState
import eu.darken.amply.main.ui.battery.ChargeTeaserState
import eu.darken.amply.main.ui.battery.ChargeTimeCard
import eu.darken.amply.stats.core.ChargeBandSplit
import eu.darken.amply.stats.core.ChargeCurvePoint
import eu.darken.amply.stats.core.ChargeSessionSummary
import eu.darken.amply.stats.core.ChargeTimeBasis
import eu.darken.amply.stats.core.ChargeTimeEstimate
import eu.darken.amply.stats.core.ChargingType
import eu.darken.amply.stats.core.CurveMetricAvailability
import eu.darken.amply.stats.core.MetricStats
import eu.darken.amply.stats.core.StatsSealReason
import eu.darken.amply.stats.ui.ChargeTimeState

// -- Content composables (one per screenshot) --------------------------------------------------

// Recording on with a finished charge behind it: every metric that varies carries a sparkline, and
// voltage — recorded but flat — deliberately carries none while still being tappable.
@Composable
internal fun HubTileGridContent() = PreviewWrapper {
HubShot(
curve = hubCurve(),
chargeTime = ChargeTimeState.Ready(
estimate = chargeTimeEstimate,
basis = ChargeTimeBasis.SAME_TYPE,
charging = true,
currentPercent = 82,
),
)
}

// Recording on but nothing recorded yet: the same six tiles, live values only, none navigable.
// Held at a limit while plugged in, so this shot also carries the longest status value there is —
// it has to wrap inside its tile rather than end in an ellipsis.
@Composable
internal fun HubTileGridEmptyContent() = PreviewWrapper {
HubShot(
readout = hubHeldReadout,
curve = emptyList(),
teaser = ChargeTeaserState.None,
chargeTime = ChargeTimeState.NotEnoughData(sessions = 0),
)
}

// One metric of one session: its latest value, its curve on a real axis, and the statistics taken
// from the raw samples (deliberately wider than the plotted curve).
@Composable
internal fun MetricDetailContent() = PreviewWrapper {
BatteryMetricDetailScreen(
state = BatteryMetricDetailState(
metric = BatteryMetric.POWER,
sessionMissing = false,
curve = hubCurve(),
stats = MetricStats(min = 1_850, avg = 9_400, max = 19_200, sampleCount = 214),
),
onBack = {},
)
}

// The charge-time card with a full projection: a countdown while charging, the three figures, the
// where-the-time-goes bar, and the taper note.
@Composable
internal fun ChargeTimeReadyContent() = PreviewWrapper {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
ChargeTimeCard(
state = ChargeTimeState.Ready(
estimate = chargeTimeEstimate,
basis = ChargeTimeBasis.SAME_TYPE,
charging = true,
currentPercent = 42,
),
)
// The same figures off the charger: a reference, never a countdown.
ChargeTimeCard(
state = ChargeTimeState.Ready(
estimate = chargeTimeEstimate,
basis = ChargeTimeBasis.POOLED,
charging = false,
currentPercent = 42,
),
)
}
}

// Recording is on but too little has been observed to project anything yet.
@Composable
internal fun ChargeTimeNotEnoughContent() = PreviewWrapper {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
ChargeTimeCard(state = ChargeTimeState.NotEnoughData(sessions = 1))
ChargeTimeCard(state = ChargeTimeState.Loading)
ChargeTimeCard(state = ChargeTimeState.Unavailable)
}
}

// -- Shared renderer + fixtures ----------------------------------------------------------------

internal val chargeTimeEstimate = ChargeTimeEstimate(
toEightyMillis = 2_040_000,
toFullMillis = 4_740_000,
avgSpeedMilliwatts = 11_500,
split = ChargeBandSplit(
toFiftyMillis = 2_700_000,
fiftyToEightyMillis = 1_800_000,
eightyToHundredMillis = 3_600_000,
),
basedOnSessions = 6,
)

@Composable
private fun HubShot(
curve: List<ChargeCurvePoint>,
readout: BatteryReadout = hubReadout,
teaser: ChargeTeaserState = ChargeTeaserState.Last(hubLastSession),
chargeTime: ChargeTimeState = ChargeTimeState.Loading,
) = BatteryHubScreen(
readout = readout,
captureEnabled = true,
teaser = teaser,
showProBadge = false,
onBack = {},
onOpenHistory = {},
onEnableCapture = {},
onOpenSession = {},
onOpenMetric = {},
curve = curve,
availability = CurveMetricAvailability.of(curve),
chargeTime = chargeTime,
)

internal val hubReadout = BatteryReadout(
levelPercent = 82,
status = BatteryManager.BATTERY_STATUS_CHARGING,
plugged = BatteryManager.BATTERY_PLUGGED_AC,
health = BatteryManager.BATTERY_HEALTH_GOOD,
technology = "Li-ion",
temperatureTenthsC = 314,
voltageMillivolts = 4_185,
currentNowMicroamps = 1_250_000,
chargeCounterMicroampHours = 3_800_000,
cycleCount = 142,
maxChargingCurrentMicroamps = 2_000_000,
maxChargingVoltageMicrovolts = 9_000_000,
)

/** The same battery, plugged in but not taking charge: the status tile's longest value. */
internal val hubHeldReadout = hubReadout.copy(
status = BatteryManager.BATTERY_STATUS_NOT_CHARGING,
currentNowMicroamps = 0,
)

/** A rising level with a tapering current, a warming battery, and a deliberately flat voltage. */
internal fun hubCurve(): List<ChargeCurvePoint> = (0..24).map { i ->
ChargeCurvePoint(
elapsedFromStartMillis = i * 300_000L,
percent = (42 + i * 2).coerceAtMost(100),
powerMilliwatts = (18_000 - i * 600).coerceAtLeast(2_000),
temperatureTenthsC = 300 + i,
voltageMillivolts = 4_185,
currentNowMicroamps = (2_400_000 - i * 80_000).coerceAtLeast(300_000),
)
}

internal val hubLastSession = ChargeSessionSummary(
id = 7,
startedAtWallMillis = 0,
endedAtWallMillis = 0,
durationMillis = 7_200_000,
startPercent = 42,
endPercent = 90,
chargingType = ChargingType.AC,
avgPowerMilliwatts = 12_000,
peakPowerMilliwatts = 27_000,
minTemperatureTenthsC = 300,
avgTemperatureTenthsC = 312,
maxTemperatureTenthsC = 324,
limitHit = false,
partial = false,
fullReachedAtWallMillis = null,
sealReason = StatsSealReason.UNPLUGGED,
)

// -- IDE previews (design-time only; the screenshotTest wrappers drive the actual capture) ------

@Preview(name = "Hub tile grid", showBackground = true, device = DS)
@Composable
private fun PreviewHubTileGrid() = HubTileGridContent()

@Preview(name = "Hub tile grid · nothing recorded", showBackground = true, device = DS)
@Composable
private fun PreviewHubTileGridEmpty() = HubTileGridEmptyContent()

@Preview(name = "Metric detail", showBackground = true, device = DS)
@Composable
private fun PreviewMetricDetail() = MetricDetailContent()

@Preview(name = "Charge time · ready", showBackground = true, device = DS)
@Composable
private fun PreviewChargeTimeReady() = ChargeTimeReadyContent()

@Preview(name = "Charge time · not enough data", showBackground = true, device = DS)
@Composable
private fun PreviewChargeTimeNotEnough() = ChargeTimeNotEnoughContent()
32 changes: 32 additions & 0 deletions app/src/main/java/eu/darken/amply/battery/ui/BatteryEffect.kt
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,35 @@ fun chargePowerFallbackRes(effect: BatteryEffect): Int = when (effect) {
BatteryEffect.Unknown,
-> R.string.battery_value_not_reported
}

/** What a half-width tile shows for a withheld charge power, and what it announces instead. */
data class ChargePowerTileFallback(
@StringRes val textRes: Int,
@StringRes val spokenRes: Int,
)

/**
* The tile-grid variant of [chargePowerFallbackRes] — same `when`, different rendering, kept
* adjacent so any future divergence between the two is visible in one place.
*
* A tile is half the screen wide and fits roughly twelve characters, so "Not charging" is a
* wrapped or ellipsised sentence in a slot whose neighbours are numbers. The dash reads as absence
* at that size and the spoken form carries the meaning that the glyph cannot, which is why this
* returns both. A label-left/value-right detail row has no such width problem, so it keeps the
* words.
*
* "Not reported" never collapses into the dash: it states a *device capability* — the platform
* exposed no figure — rather than a charging state, and a dash there would claim the battery is
* observably not charging when nothing observed it.
*/
fun chargePowerTileFallback(effect: BatteryEffect): ChargePowerTileFallback = when (effect) {
is BatteryEffect.OnBattery,
is BatteryEffect.ConnectedNotCharging,
is BatteryEffect.Full,
-> ChargePowerTileFallback(R.string.battery_value_none, R.string.battery_value_not_charging)

is BatteryEffect.Charging,
is BatteryEffect.Connected,
BatteryEffect.Unknown,
-> ChargePowerTileFallback(R.string.battery_value_not_reported, R.string.battery_value_not_reported)
}
131 changes: 131 additions & 0 deletions app/src/main/java/eu/darken/amply/common/compose/chart/Sparkline.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package eu.darken.amply.common.compose.chart

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import eu.darken.amply.common.compose.AmplyPreview
import eu.darken.amply.common.compose.PreviewWrapper

const val SPARKLINE_TEST_TAG = "chart.sparkline"

/**
* A single self-normalized series with no axes, labels or legend — the shape of one metric, small
* enough to sit inside a stat tile.
*
* Deliberately **not** [LineChart] with everything switched off: that component always renders its
* legend `FlowRow` and reserves an end-label gutter, neither of which can be disabled, so at tile
* size the result would be mostly chrome.
*
* The API takes real [ChartPoint]s rather than a bare list of values because battery samples are not
* evenly spaced in time (the recorder samples on level change as well as on its timer) — spacing
* them equally would draw a shape the session never had. X is elapsed-from-start.
*
* Nothing is drawn unless the series has an **adjacent** non-null pair with **different** values —
* the same rule `StatsCurveChart` gates its live curve on. A self-normalized series with no range
* would otherwise render as a flat line at the canvas midpoint, which reads as a plotted trend but
* is really the "no range" fallback; and a path broken at nulls draws nothing at all. Callers can
* assert presence/absence via [SPARKLINE_TEST_TAG], which only the drawn case carries.
*/
@Composable
fun Sparkline(
points: List<ChartPoint>,
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.primary,
height: Dp = 28.dp,
) {
if (!points.hasDrawableVariation()) return

Canvas(
modifier = modifier
.fillMaxWidth()
.height(height)
.testTag(SPARKLINE_TEST_TAG),
) {
val drawn = points.filter { it.y != null }
val xs = drawn.map { it.x }
val ys = drawn.map { it.y!! }
val xMin = xs.min()
val xSpan = (xs.max() - xMin).takeIf { it > 0f } ?: 1f
val yMin = ys.min()
val ySpan = (ys.max() - yMin).takeIf { it > 0f } ?: 1f
// Inset by the stroke so the extremes aren't clipped at the canvas edges.
val inset = 2f
val usableHeight = (size.height - inset * 2).coerceAtLeast(1f)

fun px(x: Float) = (x - xMin) / xSpan * size.width
fun py(y: Float) = inset + usableHeight * (1f - (y - yMin) / ySpan)

// Break the path across null gaps rather than interpolating over missing data.
var path: Path? = null
points.forEach { point ->
val y = point.y
if (y == null) {
path?.let { drawPath(it, color, style = Stroke(width = 3f, cap = StrokeCap.Round)) }
path = null
return@forEach
}
val offset = Offset(px(point.x), py(y))
val current = path
if (current == null) {
path = Path().apply { moveTo(offset.x, offset.y) }
} else {
current.lineTo(offset.x, offset.y)
}
}
path?.let { drawPath(it, color, style = Stroke(width = 3f, cap = StrokeCap.Round)) }
}
}

/**
* True when at least one **adjacent** pair of non-null samples differs — the only case a
* self-normalized single-series line can actually draw as a curve.
*/
fun List<ChartPoint>.hasDrawableVariation(): Boolean = zipWithNext().any { (first, second) ->
val a = first.y
val b = second.y
a != null && b != null && a != b
}

@AmplyPreview
@Composable
private fun SparklinePreview() = PreviewWrapper {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Rising level", style = MaterialTheme.typography.labelSmall)
Sparkline(points = (0..20).map { ChartPoint(it * 60_000f, 42f + it * 2.4f) })

Text("Uneven sample spacing, with a gap", style = MaterialTheme.typography.labelSmall)
Sparkline(
points = listOf(
ChartPoint(0f, 18_000f),
ChartPoint(30_000f, 17_200f),
ChartPoint(45_000f, 12_400f),
ChartPoint(600_000f, null),
ChartPoint(900_000f, 6_100f),
ChartPoint(1_500_000f, 2_400f),
),
color = MaterialTheme.colorScheme.tertiary,
)

// A constant series draws nothing at all rather than a fake midline — this row stays empty.
Text("Constant (renders nothing)", style = MaterialTheme.typography.labelSmall)
Sparkline(points = (0..10).map { ChartPoint(it * 60_000f, 4_185f) })
}
}
Loading