feat(onboarding): Battery -> Unrestricted help animation (#174) #439

Merged
JMR-dev merged 1 commits from feat-174-battery-usage-help into main 2026-07-08 15:19:10 +00:00
JMR-dev commented 2026-07-08 13:34:34 +00:00 (Migrated from github.com)

What & why

Closes #174.

BatteryOptimizationScreen (the onboarding "Get mail the instant it arrives" opt-in) previously guided the user with text only before deep-linking to system settings. Per #150 there is no OEM-universal intent that lands exactly on the Battery sub-screen, so a short in-app visual of the general "tap Battery -> choose Unrestricted" path adds value regardless of how precisely the deep link lands.

This adds a lightweight, dependency-free looping illustration on that screen, above the (retained) guidance text and before the "Take me there" / "Not now" actions -- so the user sees it while still in LibreMail.

Implementation

  • New BatteryGuideAnimation.kt -- a stylized Compose illustration (a faux settings card with a "Battery" row and an "Unrestricted" option). A highlight/selection moves from the Battery row to the Unrestricted option while a "tap here" dot pulses, driven by rememberInfiniteTransition.
    • Approach / no new dependency (issue asked for a deliberate choice): built from Compose primitives + core Material icons only -- no Lottie, no AnimatedVectorDrawable, nothing added to libs.versions.toml. It is deliberately generic (not a recording of any one OEM's real UI, which varies per #150).
  • Reduced motion: rememberReducedMotion() reads Settings.Global.ANIMATOR_DURATION_SCALE; when animations are off the same card renders at rest (no infinite transition).
  • TalkBack: the whole illustration exposes a single contentDescription mirroring the retained onboarding_battery_guidance text -- additive, not a replacement for the accessible path.
  • Deep link unchanged: still batterySettingsIntent() (ACTION_APPLICATION_DETAILS_SETTINGS app-details, per #150 / BatteryOptimizationManager); the ON_RESUME re-check and Take-me-there / Not-now flow are untouched. The column is now scrollable so the extra content never pushes the actions off-screen.
  • Logging: PII-free AppLog breadcrumbs at shown (with unrestricted / reducedMotion booleans), opening settings, skipped, plus a warn on intent-launch failure.

Placement

On the existing BatteryOptimizationScreen step (where the issue asked), only in the "offered" (not-yet-unrestricted) state, between the body text and the guidance/buttons.

Tests

  • Robolectric JVM (BatteryGuideAnimationJvmTest, +extended BatteryOptimizationScreenJvmTest): animated variant across both steps (hand-driven clock so the infinite transition can't hang waitForIdle), static reduced-motion variant, the default-arg path, and the isReducedMotion decision; the screen shows the guide in the offered state and not in the "done" state, and the enable button still fires the settings intent.
  • Instrumented (BatteryOptimizationStepTest): asserts the guide is shown in the onboarding flow and scrolls to the actions; disables device animations (as CI's emulator-runner does) so the static path renders on-device.

Gate

Local fast gate green: assembleDebug, testDebugUnitTest, jacocoTestCoverageVerification (0.84 floor), compileDebugAndroidTestKotlin, lintDebug, ktlintCheck, detekt. Full multi-API E2E matrix left to CI.

## What & why Closes #174. `BatteryOptimizationScreen` (the onboarding "Get mail the instant it arrives" opt-in) previously guided the user with **text only** before deep-linking to system settings. Per #150 there is no OEM-universal intent that lands exactly on the Battery sub-screen, so a short in-app visual of the general "tap Battery -> choose Unrestricted" path adds value regardless of how precisely the deep link lands. This adds a lightweight, **dependency-free** looping illustration on that screen, above the (retained) guidance text and before the "Take me there" / "Not now" actions -- so the user sees it while still in LibreMail. ## Implementation - **New `BatteryGuideAnimation.kt`** -- a stylized Compose illustration (a faux settings card with a "Battery" row and an "Unrestricted" option). A highlight/selection moves from the Battery row to the Unrestricted option while a "tap here" dot pulses, driven by `rememberInfiniteTransition`. - **Approach / no new dependency** (issue asked for a deliberate choice): built from Compose primitives + core Material icons only -- **no Lottie, no `AnimatedVectorDrawable`**, nothing added to `libs.versions.toml`. It is deliberately **generic** (not a recording of any one OEM's real UI, which varies per #150). - **Reduced motion:** `rememberReducedMotion()` reads `Settings.Global.ANIMATOR_DURATION_SCALE`; when animations are off the same card renders **at rest** (no infinite transition). - **TalkBack:** the whole illustration exposes a single `contentDescription` mirroring the retained `onboarding_battery_guidance` text -- additive, not a replacement for the accessible path. - **Deep link unchanged:** still `batterySettingsIntent()` (`ACTION_APPLICATION_DETAILS_SETTINGS` app-details, per #150 / `BatteryOptimizationManager`); the `ON_RESUME` re-check and Take-me-there / Not-now flow are untouched. The column is now scrollable so the extra content never pushes the actions off-screen. - **Logging:** PII-free `AppLog` breadcrumbs at *shown* (with `unrestricted` / `reducedMotion` booleans), *opening settings*, *skipped*, plus a warn on intent-launch failure. ## Placement On the existing `BatteryOptimizationScreen` step (where the issue asked), only in the "offered" (not-yet-unrestricted) state, between the body text and the guidance/buttons. ## Tests - **Robolectric JVM** (`BatteryGuideAnimationJvmTest`, +extended `BatteryOptimizationScreenJvmTest`): animated variant across both steps (hand-driven clock so the infinite transition can't hang `waitForIdle`), static reduced-motion variant, the default-arg path, and the `isReducedMotion` decision; the screen shows the guide in the offered state and not in the "done" state, and the enable button still fires the settings intent. - **Instrumented** (`BatteryOptimizationStepTest`): asserts the guide is shown in the onboarding flow and scrolls to the actions; disables device animations (as CI's emulator-runner does) so the static path renders on-device. ## Gate Local fast gate green: `assembleDebug`, `testDebugUnitTest`, `jacocoTestCoverageVerification` (0.84 floor), `compileDebugAndroidTestKotlin`, `lintDebug`, `ktlintCheck`, `detekt`. Full multi-API E2E matrix left to CI.
mergify[bot] commented 2026-07-08 13:58:38 +00:00 (Migrated from github.com)

Merge Queue Status

This pull request spent 1 hour 20 minutes 48 seconds in the queue, including 1 hour 19 minutes 41 seconds running CI.

Required conditions to merge
<!--- DO NOT EDIT -*- Mergify Payload -*- {"version": 1, "state": "merged", "queue_rule_name": "default", "queued_at": "2026-07-08T13:58:27.090098+00:00", "estimated_time_of_merge": null, "speculative_check_pr": null, "required_conditions": []} -*- Mergify Payload End -*- --> # Merge Queue Status - ✅ **Entered queue** — `2026-07-08 13:58 UTC` · Rule: `default` · triggered by merge protections - ❌ **Checks failed** — `2026-07-08 13:58 UTC` · on draft #441 - ✂️ Bisecting to identify the failing PR (round 1/1) - ✅ **Checks passed** · on draft #442 - ✅ **Merged** — `2026-07-08 15:19 UTC` · at `b038c3bdae5e557c1044cd1ad5d52e534f3ecc2d` · merge This pull request spent **1 hour 20 minutes 48 seconds** in the queue, including **1 hour 19 minutes 41 seconds** running CI. <details> <summary>Required conditions to merge</summary> - `-conflict` - [X] #439 - `-draft` - [X] #439 - [X] `base = main` - [X] `check-success = CI passed` - `github-review-approved` [🛡 GitHub repository ruleset rule `main`] - [X] #439 - `label != broken` - [X] #439 - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = Debug build` - [ ] `check-neutral = Debug build` - [ ] `check-skipped = Debug build` - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = Unit tests` - [ ] `check-neutral = Unit tests` - [ ] `check-skipped = Unit tests` - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = CI passed` - [ ] `check-neutral = CI passed` - [ ] `check-skipped = CI passed` - [X] any of [🛡 GitHub repository ruleset rule `main`]: - [X] `check-success = @github-actions/CI passed` - [ ] `check-neutral = @github-actions/CI passed` - [ ] `check-skipped = @github-actions/CI passed` </details>
Sign in to join this conversation.