Skip to content

Commit be4f0fe

Browse files
committed
Dashboard: Surface an interrupted-session warning card
Fold the interruption event into the dashboard state and render a dismissible warning card directly under the status hero, only on a device whose charge-control support is positively resolved and only when an event is present. The title reflects the outcome (restored / needs attention / unconfirmed) and the body pairs a neutral reason sentence with an outcome sentence; a single Dismiss button clears it. An explicit policy write from the dashboard supersedes a non-successful warning: applyPolicy clears a pending event and cancels the recovery notification on a successful write. Wiring stays at the composition root (MainActivity passes onDismissInterruption) so the screen remains pure and previewable; the ready-dashboard preview gains a restored-late fixture.
1 parent 94785e4 commit be4f0fe

7 files changed

Lines changed: 249 additions & 7 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ private fun DashboardShot(state: DashboardUiState) = PreviewWrapper {
104104
onOpenSupportIssue = {},
105105
onEmailSupport = {},
106106
onHelp = {},
107+
onDismissInterruption = {},
107108
)
108109
}
109110

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ class MainActivity : ComponentActivity() {
272272
onPinWidget = viewModel::requestPinWidget,
273273
onAddTile = viewModel::requestAddTile,
274274
onDismissQuickAccess = viewModel::dismissQuickAccess,
275+
onDismissInterruption = viewModel::dismissInterruption,
275276
onNativeSettings = viewModel::openNativeSettings,
276277
onOpenShizuku = viewModel::openShizuku,
277278
onAllowShizuku = viewModel::requestShizukuPermission,

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ import eu.darken.amply.common.compose.AmplyToggleCard
7777
import eu.darken.amply.common.compose.PreviewWrapper
7878
import eu.darken.amply.common.compose.asComposable
7979
import eu.darken.amply.fullcharge.core.ChargeSessionRecord
80+
import eu.darken.amply.fullcharge.core.InterruptionEvent
81+
import eu.darken.amply.fullcharge.core.InterruptionOutcome
82+
import eu.darken.amply.fullcharge.core.InterruptionReason
8083
import eu.darken.amply.fullcharge.core.policyOrNull
8184
import eu.darken.amply.main.core.formatReport
8285
import eu.darken.amply.battery.core.BatteryReadout
@@ -113,6 +116,7 @@ fun DashboardScreen(
113116
onPinWidget: () -> Unit,
114117
onAddTile: () -> Unit,
115118
onDismissQuickAccess: () -> Unit,
119+
onDismissInterruption: () -> Unit,
116120
onNativeSettings: () -> Unit,
117121
onOpenShizuku: () -> Unit,
118122
onAllowShizuku: () -> Unit,
@@ -203,6 +207,13 @@ fun DashboardScreen(
203207
)
204208
}
205209
} else {
210+
// Interrupted-session warning sits right under the hero, but only on a device
211+
// whose support is positively resolved (a live adapter is selected). Silent
212+
// otherwise — a warning about a restore Amply cannot even perform would confuse.
213+
val interruption = state.interruption
214+
if (interruption != null && state.charging.controlEnabled) {
215+
item { InterruptionCard(interruption, onDismissInterruption) }
216+
}
206217
// Shizuku-only adapters (OnePlus/ColorOS) can't use WSS at all, so the WSS/ADB
207218
// setup guide would ask for an ineffective grant — the dedicated
208219
// "Shizuku required" banner below covers their setup instead.
@@ -740,6 +751,46 @@ private fun ShizukuBanner(
740751
}
741752
}
742753

754+
@Composable
755+
private fun InterruptionCard(
756+
event: InterruptionEvent,
757+
onDismiss: () -> Unit,
758+
) {
759+
// Tertiary-container "attention" tone, matching the ShizukuBanner. Copy is deliberately neutral —
760+
// the reason never asserts a force-stop, only that Amply was stopped/interrupted.
761+
AmplyCard(tone = AmplyCardTone.TertiaryContainer) {
762+
Text(
763+
stringResource(
764+
when (event.outcome) {
765+
InterruptionOutcome.RESTORED_LATE -> R.string.dashboard_interruption_title_restored
766+
InterruptionOutcome.STILL_PENDING -> R.string.dashboard_interruption_title_pending
767+
InterruptionOutcome.UNCONFIRMED -> R.string.dashboard_interruption_title_unconfirmed
768+
},
769+
),
770+
style = MaterialTheme.typography.titleSmall,
771+
fontWeight = FontWeight.SemiBold,
772+
)
773+
val reason = stringResource(
774+
when (event.reason) {
775+
InterruptionReason.USER_STOPPED -> R.string.dashboard_interruption_reason_user
776+
InterruptionReason.OTHER -> R.string.dashboard_interruption_reason_other
777+
},
778+
)
779+
val outcome = stringResource(
780+
when (event.outcome) {
781+
InterruptionOutcome.RESTORED_LATE -> R.string.dashboard_interruption_outcome_restored
782+
InterruptionOutcome.STILL_PENDING -> R.string.dashboard_interruption_outcome_pending
783+
InterruptionOutcome.UNCONFIRMED -> R.string.dashboard_interruption_outcome_unconfirmed
784+
},
785+
)
786+
Text("$reason $outcome", style = MaterialTheme.typography.bodySmall)
787+
Spacer(Modifier.height(8.dp))
788+
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
789+
Text(stringResource(R.string.dashboard_interruption_dismiss_action))
790+
}
791+
}
792+
}
793+
743794
private fun ChargeObservation.title(): CaString = when (this) {
744795
is ChargeObservation.Verified -> if (backend == BackendKind.BATTERY_HARDWARE) {
745796
caString { it.getString(R.string.dashboard_status_verified_active, policy.shortLabel().get(it)) }
@@ -802,6 +853,13 @@ private fun DashboardScreenPreview() = PreviewWrapper {
802853
quickFullChargeEnabled = true,
803854
// Presence check done, nothing discovered yet — renders the quick-access promotion.
804855
quickAccessChecked = true,
856+
// A resolved interruption: the warning card sits under the hero until dismissed.
857+
interruption = InterruptionEvent(
858+
occurredAtMillis = 0L,
859+
reason = InterruptionReason.USER_STOPPED,
860+
outcome = InterruptionOutcome.RESTORED_LATE,
861+
workId = "preview",
862+
),
805863
// Held at the 80% limit: paired with the policy so the reading reads as the effect.
806864
batteryReadout = BatteryReadout(
807865
levelPercent = 80,
@@ -852,6 +910,7 @@ private fun DashboardScreenPreview() = PreviewWrapper {
852910
onPinWidget = {},
853911
onAddTile = {},
854912
onDismissQuickAccess = {},
913+
onDismissInterruption = {},
855914
onNativeSettings = {},
856915
onOpenShizuku = {},
857916
onAllowShizuku = {},
@@ -945,6 +1004,7 @@ private fun DashboardScreenLiveChargePreview() = PreviewWrapper {
9451004
onPinWidget = {},
9461005
onAddTile = {},
9471006
onDismissQuickAccess = {},
1007+
onDismissInterruption = {},
9481008
onNativeSettings = {},
9491009
onOpenShizuku = {},
9501010
onAllowShizuku = {},
@@ -1020,6 +1080,7 @@ private fun DashboardScreenApplyingPreview() = PreviewWrapper {
10201080
onPinWidget = {},
10211081
onAddTile = {},
10221082
onDismissQuickAccess = {},
1083+
onDismissInterruption = {},
10231084
onNativeSettings = {},
10241085
onOpenShizuku = {},
10251086
onAllowShizuku = {},
@@ -1091,6 +1152,7 @@ private fun DashboardScreenSessionActivePreview() = PreviewWrapper {
10911152
onPinWidget = {},
10921153
onAddTile = {},
10931154
onDismissQuickAccess = {},
1155+
onDismissInterruption = {},
10941156
onNativeSettings = {},
10951157
onOpenShizuku = {},
10961158
onAllowShizuku = {},
@@ -1164,6 +1226,7 @@ private fun DashboardScreenSessionRecordedPreview() = PreviewWrapper {
11641226
onPinWidget = {},
11651227
onAddTile = {},
11661228
onDismissQuickAccess = {},
1229+
onDismissInterruption = {},
11671230
onNativeSettings = {},
11681231
onOpenShizuku = {},
11691232
onAllowShizuku = {},
@@ -1225,6 +1288,7 @@ private fun DashboardScreenWssOnlyPreview() = PreviewWrapper {
12251288
onPinWidget = {},
12261289
onAddTile = {},
12271290
onDismissQuickAccess = {},
1291+
onDismissInterruption = {},
12281292
onNativeSettings = {},
12291293
onOpenShizuku = {},
12301294
onAllowShizuku = {},
@@ -1293,6 +1357,7 @@ private fun DashboardScreenSamsungPreview() = PreviewWrapper {
12931357
onPinWidget = {},
12941358
onAddTile = {},
12951359
onDismissQuickAccess = {},
1360+
onDismissInterruption = {},
12961361
onNativeSettings = {},
12971362
onOpenShizuku = {},
12981363
onAllowShizuku = {},
@@ -1362,6 +1427,7 @@ private fun DashboardScreenOnePlusNeedsShizukuPreview() = PreviewWrapper {
13621427
onPinWidget = {},
13631428
onAddTile = {},
13641429
onDismissQuickAccess = {},
1430+
onDismissInterruption = {},
13651431
onNativeSettings = {},
13661432
onOpenShizuku = {},
13671433
onAllowShizuku = {},
@@ -1416,6 +1482,7 @@ private fun DashboardScreenUnsupportedPreview() = PreviewWrapper {
14161482
onPinWidget = {},
14171483
onAddTile = {},
14181484
onDismissQuickAccess = {},
1485+
onDismissInterruption = {},
14191486
onNativeSettings = {},
14201487
onOpenShizuku = {},
14211488
onAllowShizuku = {},

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

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ import eu.darken.amply.fullcharge.core.ChargeSessionManager
3636
import eu.darken.amply.fullcharge.core.ChargeSessionRecord
3737
import eu.darken.amply.fullcharge.core.ChargeSessionService
3838
import eu.darken.amply.fullcharge.core.FullChargeStore
39+
import eu.darken.amply.fullcharge.core.InterruptionEvent
40+
import eu.darken.amply.fullcharge.core.InterruptionStore
3941
import eu.darken.amply.fullcharge.core.ServiceDispatch
42+
import eu.darken.amply.fullcharge.core.SessionNotifications
4043
import eu.darken.amply.main.core.DeviceSupportReport
4144
import eu.darken.amply.main.core.DeviceSupportReporter
4245
import eu.darken.amply.main.core.OnboardingSettings
@@ -102,6 +105,8 @@ data class DashboardUiState(
102105
val notificationsBlocked: Boolean = false,
103106
/** Everything the stats card needs: capture switch, pipeline health, and the Room teaser data. */
104107
val stats: StatsDashboardState = StatsDashboardState(),
108+
/** A pending "Amply was interrupted while owing a restore" warning, or null when there is none. */
109+
val interruption: InterruptionEvent? = null,
105110
)
106111

107112
@HiltViewModel
@@ -118,6 +123,7 @@ class DashboardViewModel @Inject constructor(
118123
private val statsPreferences: StatsPreferences,
119124
private val statsRepository: ChargeStatsRepository,
120125
private val captureServiceHealth: CaptureServiceHealth,
126+
private val interruptionStore: InterruptionStore,
121127
private val watchers: Set<@JvmSuppressWildcards ChargeMonitorWatcher>,
122128
) : ViewModel() {
123129
private val deviceReport = MutableStateFlow<DeviceSupportReport?>(null)
@@ -133,12 +139,22 @@ class DashboardViewModel @Inject constructor(
133139
) { enabled, anyLevel -> enabled to anyLevel }
134140

135141
// Grouped so the outer combine keeps one slot each: the live battery readout, the alarm config,
136-
// and the notifications-blocked flag.
142+
// the notifications-blocked flag, and the interrupted-session warning.
137143
private val unprivilegedExtras = combine(
138144
batteryReadoutSource.readouts(),
139145
chargeAlarmStore.config,
140146
notificationsBlocked,
141-
) { readout, alarm, blocked -> Triple(readout, alarm, blocked) }
147+
interruptionStore.event,
148+
) { readout, alarm, blocked, interruption ->
149+
UnprivilegedExtras(readout, alarm, blocked, interruption)
150+
}
151+
152+
private data class UnprivilegedExtras(
153+
val readout: BatteryReadout?,
154+
val alarm: ChargeAlarmConfig,
155+
val notificationsBlocked: Boolean,
156+
val interruption: InterruptionEvent?,
157+
)
142158

143159
// Battery-statistics dashboard teaser. The session/count reads (which open the stats Room DB)
144160
// run only while capture is enabled — when it's off the card shows a promo, so a user who never
@@ -173,14 +189,15 @@ class DashboardViewModel @Inject constructor(
173189
tileRequestPending,
174190
unprivilegedExtras,
175191
statsDashboard,
176-
) { base, quickAccess, checked, tilePending, (readout, alarm, blocked), stats ->
192+
) { base, quickAccess, checked, tilePending, extras, stats ->
177193
base.copy(
178194
quickAccess = quickAccess,
179195
quickAccessChecked = checked,
180196
tileRequestPending = tilePending,
181-
batteryReadout = readout,
182-
alarm = alarm,
183-
notificationsBlocked = blocked,
197+
batteryReadout = extras.readout,
198+
alarm = extras.alarm,
199+
notificationsBlocked = extras.notificationsBlocked,
200+
interruption = extras.interruption,
184201
stats = stats,
185202
)
186203
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DashboardUiState())
@@ -327,7 +344,13 @@ class DashboardViewModel @Inject constructor(
327344
fun applyPolicy(policy: ChargePolicy) = viewModelScope.launch {
328345
log(TAG, Logging.Priority.INFO) { "applyPolicy(${policy.stableId})" }
329346
if (fullChargeStore.currentSession() != null) sessionManager.cancelWithoutRestore()
330-
repository.applyPersistent(policy)
347+
val result = repository.applyPersistent(policy)
348+
if (result.success) {
349+
// An explicit policy choice supersedes any non-successful interruption warning and its
350+
// lingering recovery notification.
351+
interruptionStore.clearPending()
352+
SessionNotifications.cancelRecovery(context)
353+
}
331354
// The persistent policy is an any-level arming input; nudge a running gesture monitor so
332355
// arming and notification copy react now instead of on the next broadcast/30s poll.
333356
if (fullChargeStore.isQuickFullChargeEnabled()) {
@@ -446,6 +469,8 @@ class DashboardViewModel @Inject constructor(
446469

447470
fun dismissQuickAccess() = viewModelScope.launch { quickAccessStore.dismiss() }
448471

472+
fun dismissInterruption() = viewModelScope.launch { interruptionStore.clear() }
473+
449474
fun requestPinWidget() {
450475
// The pin dialog is modal once shown, but rapid taps before it appears would queue
451476
// multiple launcher requests — swallow them.

app/src/main/res/values/strings.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,17 @@
387387
<string name="dashboard_quickaccess_widget_manual">Widget pinning isn\'t available here. Long-press your home screen and add the Amply widget from the widget picker.</string>
388388
<string name="dashboard_quickaccess_tile_manual">Open Quick Settings, tap the edit button, and drag the Amply tile into place.</string>
389389

390+
<!-- Interrupted-session warning card -->
391+
<string name="dashboard_interruption_title_restored">Charge limit restored after interruption</string>
392+
<string name="dashboard_interruption_title_pending">Charge limit needs attention</string>
393+
<string name="dashboard_interruption_title_unconfirmed">Charge limit restore unconfirmed</string>
394+
<string name="dashboard_interruption_reason_user">Amply was stopped while it was watching a temporary full charge.</string>
395+
<string name="dashboard_interruption_reason_other">Amply was interrupted while it was watching a temporary full charge.</string>
396+
<string name="dashboard_interruption_outcome_restored">Your charge limit has been restored now.</string>
397+
<string name="dashboard_interruption_outcome_pending">Your charge limit could not be restored yet — Amply keeps retrying. Check the current policy below.</string>
398+
<string name="dashboard_interruption_outcome_unconfirmed">Your charge limit was written again, but the charging hardware has not confirmed it. Check the current policy below.</string>
399+
<string name="dashboard_interruption_dismiss_action">Dismiss</string>
400+
390401
<!-- Charge alarm (dashboard card + notification) -->
391402
<string name="dashboard_alarm_title">Charge alarm</string>
392403
<string name="dashboard_alarm_body_on">Amply will alert you to unplug when charging reaches your target. Works without any special setup.</string>

0 commit comments

Comments
 (0)