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
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ data class BatteryReadout(
val chargeCounterMicroampHours: Int? = null,
val cycleCount: Int? = null,
) {
/**
* External power is reported. A null (not reported) [plugged] collapses conservatively to false —
* nothing may claim a charger it can't observe. This is the single "on the charger" rule; it is
* deliberately independent of [status], because a device held at a charge limit reports
* `BATTERY_STATUS_NOT_CHARGING` while still connected.
*/
val onCharger: Boolean get() = (plugged ?: 0) != 0

companion object {
val UNKNOWN = BatteryReadout()
}
Expand Down
9 changes: 7 additions & 2 deletions app/src/main/java/eu/darken/amply/common/compose/AmplyCard.kt
Original file line number Diff line number Diff line change
Expand Up @@ -310,8 +310,13 @@ fun AmplyCardHeader(

/**
* Navigation card: the whole card opens a destination ([onClick]) with an [onClickLabel] for
* accessibility, a standard header, and a decorative RTL-aware trailing chevron. Must contain no
* independent interactive controls — the surface owns the single tap.
* accessibility, a standard header, and a decorative RTL-aware trailing chevron.
*
* The surface owns the primary tap. Content may add at most a small trailing text action for a
* *secondary* destination or side effect (never a duplicate of [onClick]); anything richer — switches,
* multiple buttons, a whole control row — belongs in [AmplyToggleCard] or a plain [AmplyCard]. A
* nested action must not bubble to the surface: assert both routes separately in tests, as
* `StatsDashboardCard`'s "History" and "Retry" actions do.
*/
@Composable
fun AmplyNavigationCard(
Expand Down
27 changes: 10 additions & 17 deletions app/src/main/java/eu/darken/amply/main/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,6 @@ class MainActivity : ComponentActivity() {
var destination by rememberSaveable { mutableStateOf(SettingsDestination.DASHBOARD) }
// Where a back-out of the contribution wizard returns to (set on each entry).
var wizardOrigin by rememberSaveable { mutableStateOf(SettingsDestination.DASHBOARD) }
// Battery statistics is reachable from both the dashboard card and the settings hub;
// remember which so back returns there.
var statsOrigin by rememberSaveable { mutableStateOf(SettingsDestination.SETTINGS) }
// Where the session-detail screen returns to: the stats list when opened from it, or
// the dashboard when deep-linked from the live "on the charger" card.
var detailOrigin by rememberSaveable { mutableStateOf(SettingsDestination.STATS) }
Expand Down Expand Up @@ -207,17 +204,18 @@ class MainActivity : ComponentActivity() {
when (destination) {
// The wizard clears its raw session and returns to whichever surface opened it.
SettingsDestination.DIAGNOSTICS -> leaveWizard()
// These are entered from the dashboard, not the settings hub.
// These are entered from the dashboard, not the settings hub — the stats list
// included, since the dashboard card is its only entry point.
SettingsDestination.SETTINGS,
SettingsDestination.RECONNECT_GESTURE,
SettingsDestination.BATTERY_DETAIL -> destination = SettingsDestination.DASHBOARD
// The session detail returns to the stats list; the list returns to wherever
// it was opened from (dashboard card or settings hub).
SettingsDestination.BATTERY_DETAIL,
SettingsDestination.STATS -> destination = SettingsDestination.DASHBOARD
// The session detail returns to the stats list, or to the dashboard when it was
// deep-linked from the live card.
SettingsDestination.STATS_SESSION_DETAIL -> {
statsViewModel.closeSession()
destination = detailOrigin
}
SettingsDestination.STATS -> destination = statsOrigin
else -> destination = SettingsDestination.SETTINGS
}
}
Expand Down Expand Up @@ -256,10 +254,7 @@ class MainActivity : ComponentActivity() {
onAlarmTargetChange = viewModel::setChargeAlarmTarget,
onFixNotifications = viewModel::openNotificationSettings,
onOpenBatteryDetail = { destination = SettingsDestination.BATTERY_DETAIL },
onOpenStats = {
statsOrigin = SettingsDestination.DASHBOARD
destination = SettingsDestination.STATS
},
onOpenStats = { destination = SettingsDestination.STATS },
onOpenLiveSession = { id ->
// Deep-link straight to the in-progress session's detail; back returns
// to the dashboard the card lives on.
Expand Down Expand Up @@ -289,10 +284,6 @@ class MainActivity : ComponentActivity() {
SettingsDestination.SETTINGS -> SettingsScreen(
onBack = { destination = SettingsDestination.DASHBOARD },
onGeneral = { destination = SettingsDestination.GENERAL },
onStats = {
statsOrigin = SettingsDestination.SETTINGS
destination = SettingsDestination.STATS
},
// Offered whenever this device is one we want contribution data for (unsupported/lab),
// regardless of whether Shizuku is installed yet — the wizard nudges the install.
showDiagnostics = state.charging.contributionWanted,
Expand Down Expand Up @@ -367,7 +358,9 @@ class MainActivity : ComponentActivity() {
val statsState by statsViewModel.state.collectAsState()
StatsScreen(
state = statsState,
onBack = { destination = statsOrigin },
// Only the dashboard's stats card opens this screen, so back always
// returns there.
onBack = { destination = SettingsDestination.DASHBOARD },
onCaptureEnabledChange = { enabled ->
if (enabled) {
runWithNotifications(NotificationAction.ENABLE_STATS)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.OpenInNew
Expand Down Expand Up @@ -155,24 +156,52 @@ fun DashboardScreen(
// Derived once and shared by the status + full-charge cards so they can never disagree
// about whether the one-time charge is actually in effect.
val sessionPresentation = SessionPresentation.from(state.session, state.charging.observation)
// The single stats card sits in one fixed slot (shared tail below); only its content
// adapts. "On the charger" is decided by the raw battery readout, never the recorder's
// DB row — see StatsCardPresentation for the truth rules.
// There is one stats card; only its content adapts. "On the charger" is decided by the raw
// battery readout, never the recorder's DB row — see StatsCardPresentation for the truth
// rules.
val statsPresentation = StatsCardPresentation.from(state.stats, state.batteryReadout)
// Its slot is a separate, purely plug-driven decision: on the charger it is promoted to the
// second card (below the interruption warning and the setup guide, which are rarer and
// actionable), otherwise it stays in the shared tail. Deliberately independent of the
// content state — a promo/loading/unavailable card is promoted too, so the slot never
// changes underneath the user as the stats DB answers.
val statsPromoted = state.batteryReadout?.onCharger == true
val unsupported = state.charging.observation is ChargeObservation.Unsupported
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = sidePadding, end = sidePadding, top = 8.dp, bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { StatusCard(state, sessionPresentation, onOpenBatteryDetail) }
// Every card carries a stable key. Without one a LazyColumn identifies items by index, so
// the stats card changing slot would renumber everything after it and throw away their
// remembered state — e.g. the alarm card's in-flight slider drag would snap back if the
// charger were plugged in mid-gesture. Keys are unique per render: the pairs that appear
// in both the supported and unsupported branches are mutually exclusive.
//
// One construction site for the stats card — only its slot varies (see statsPromoted),
// so the supported/unsupported branches below can't drift apart.
val statsItem: LazyListScope.() -> Unit = {
item(key = "dashboard.stats") {
StatsDashboardCard(
presentation = statsPresentation,
onOpenStats = onOpenStats,
onOpenLiveSession = onOpenLiveSession,
onRetryCapture = onRetryCapture,
)
}
}

item(key = "dashboard.hero") { StatusCard(state, sessionPresentation, onOpenBatteryDetail) }

if (unsupported) {
// Promoted slot: no interruption card or setup guide exists on this branch, so on
// the charger the stats card follows the hero directly.
if (statsPromoted) statsItem()
// Unsupported devices cannot use the Pixel policy/charge controls; showing them
// greyed-out (and a "Pixel settings" action) only confuses non-Pixel users. Offer
// a contribution path instead, and keep only the restore card if a session lingers.
if (state.session != null) {
item {
item(key = "dashboard.fullcharge") {
FullChargeCard(
presentation = sessionPresentation,
canControl = state.charging.controlEnabled &&
Expand All @@ -186,7 +215,7 @@ fun DashboardScreen(
// after it was enabled), keep the card so the user can turn it — and its foreground
// service — off. The card renders as disable-only when control isn't available.
if (state.quickFullChargeEnabled) {
item {
item(key = "dashboard.reconnect") {
QuickFullChargeCard(
enabled = true,
anyLevel = state.quickFullChargeAnyLevel,
Expand All @@ -198,7 +227,7 @@ fun DashboardScreen(
}
}
// Point unsupported devices at their OEM's own charge-protection setting.
item {
item(key = "dashboard.oemguide") {
OemGuideCard(
manufacturer = state.charging.device.manufacturer
.ifBlank { stringResource(R.string.dashboard_manufacturer_fallback) },
Expand All @@ -212,13 +241,13 @@ fun DashboardScreen(
// otherwise — a warning about a restore Amply cannot even perform would confuse.
val interruption = state.interruption
if (interruption != null && state.charging.controlEnabled) {
item { InterruptionCard(interruption, onDismissInterruption) }
item(key = "dashboard.interruption") { InterruptionCard(interruption, onDismissInterruption) }
}
// Shizuku-only adapters (OnePlus/ColorOS) can't use WSS at all, so the WSS/ADB
// setup guide would ask for an ineffective grant — the dedicated
// "Shizuku required" banner below covers their setup instead.
if (!state.charging.writeRequiresShizuku && state.charging.access?.direct?.ready != true) {
item {
item(key = "dashboard.setupguide") {
AccessSetupGuide(
state = state,
adbCommand = adbCommand,
Expand All @@ -231,19 +260,23 @@ fun DashboardScreen(
}
}

item {
// Promoted slot: below the interruption warning and the setup guide above, so a
// restore that is still owed and an unfinished setup keep the top of the list.
if (statsPromoted) statsItem()

item(key = "dashboard.fullcharge") {
FullChargeCard(
presentation = sessionPresentation,
canControl = state.charging.canApply,
onStart = onStartFull,
onRestore = onRestore,
)
}
item { PolicyCard(state, onApply, onNativeSettings) }
item(key = "dashboard.policy") { PolicyCard(state, onApply, onNativeSettings) }
// Hidden where the adapter lacks the gesture's hardware signal (non-Pixel) —
// unless it is still switched on and needs a way to be turned off.
if (state.charging.reconnectSupported || state.quickFullChargeEnabled) {
item {
item(key = "dashboard.reconnect") {
QuickFullChargeCard(
enabled = state.quickFullChargeEnabled,
anyLevel = state.quickFullChargeAnyLevel,
Expand All @@ -255,9 +288,10 @@ fun DashboardScreen(
}
}

// Shared tail: the alarm and the single stats card render on every device class —
// one lexical slot each, so the supported/unsupported branches can't drift apart.
item {
// Shared tail: the alarm renders on every device class, and the stats card joins it here
// whenever it wasn't promoted above — one lexical slot each, so the supported and
// unsupported branches can't drift apart.
item(key = "dashboard.alarm") {
ChargeAlarmCard(
config = state.alarm,
notificationsBlocked = state.notificationsBlocked,
Expand All @@ -266,18 +300,11 @@ fun DashboardScreen(
onFixNotifications = onFixNotifications,
)
}
item {
StatsDashboardCard(
presentation = statsPresentation,
onOpenStats = onOpenStats,
onOpenLiveSession = onOpenLiveSession,
onRetryCapture = onRetryCapture,
)
}
if (!statsPromoted) statsItem()

if (unsupported) {
if (state.charging.contributionWanted) {
item {
item(key = "dashboard.unsupported") {
UnsupportedDeviceCard(
manufacturer = state.charging.device.manufacturer
.ifBlank { stringResource(R.string.dashboard_manufacturer_fallback) },
Expand All @@ -302,7 +329,7 @@ fun DashboardScreen(
quickAccess = state.quickAccess,
)
) {
item {
item(key = "dashboard.quickaccess") {
QuickAccessCard(
widgetAdded = state.quickAccess.widgetAdded,
tileAdded = state.quickAccess.tileAdded,
Expand All @@ -320,7 +347,7 @@ fun DashboardScreen(
// disabled until it is connected.
state.charging.controlEnabled &&
state.charging.writeRequiresShizuku &&
access?.shizuku?.ready != true -> item {
access?.shizuku?.ready != true -> item(key = "dashboard.shizukubanner") {
ShizukuBanner(
running = access?.shizuku?.available == true,
requiredForControl = true,
Expand All @@ -332,7 +359,7 @@ fun DashboardScreen(
// (computer) path: durable control is present but Shizuku isn't, so exact
// readback/diagnostics are missing. Sync-readback adapters verify through
// any backend, so the readback pitch would be wrong there.
access?.direct?.ready == true && !access.canVerify && !state.charging.syncVerification -> item {
access?.direct?.ready == true && !access.canVerify && !state.charging.syncVerification -> item(key = "dashboard.shizukubanner") {
ShizukuBanner(
running = access.shizuku.available,
requiredForControl = false,
Expand Down Expand Up @@ -926,7 +953,8 @@ private fun DashboardScreenPreview() = PreviewWrapper {
)
}

// On the charger with capture enabled: the stats card in its fixed slot renders the live session.
// On the charger with capture enabled: the stats card is promoted to the second slot and renders the
// live session (the unplugged preview above shows the same card in its tail slot).
@AmplyPreview
@Composable
private fun DashboardScreenLiveChargePreview() = PreviewWrapper {
Expand Down
Loading
Loading