diff --git a/README.md b/README.md
index 6ad200c..38d3b38 100644
--- a/README.md
+++ b/README.md
@@ -132,8 +132,6 @@ This is free, open-source software: you may use, study, modify, and share it, **
Copyright © 2024–2026 Al-Mothafar Al-Hasan
-**Acknowledgements:** the battery gauge (`CircularProgressBar`) is derived from [circular_progress_bar](https://github.com/ylyc/circular_progress_bar) by Leon Cheng, used under the Apache License 2.0 — see [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md).
-
---
## 🏆 Code Quality
diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
deleted file mode 100644
index 1d61f19..0000000
--- a/THIRD-PARTY-NOTICES.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# Third-Party Notices
-
-Simple Battery Notifier is licensed under the GNU GPL v3 (see [LICENSE.md](LICENSE.md)).
-It includes third-party code that is provided under its own license, listed below.
-
-## circular_progress_bar
-
-- **Source:** https://github.com/ylyc/circular_progress_bar
-- **Copyright:** Copyright (C) 2013 Leon Cheng
-- **License:** Apache License, Version 2.0 — a full copy is provided in
- [`licenses/Apache-2.0.txt`](licenses/Apache-2.0.txt), and is also available at
- http://www.apache.org/licenses/LICENSE-2.0
-
-The file
-`app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/CircularProgressBar.java`
-is **derived from** this project and has been **modified** (it extends `ProgressBar`
-with a 270° gauge, adds the `cpb_*` styleable, auto-fitting title/subtitle, and a
-pulse/glow animation, among other changes).
-
-The Apache License 2.0 is compatible with the GNU GPL v3. As permitted by that
-license, the derived file and the combined work are distributed under the GNU GPL v3,
-while the original portions remain subject to the Apache 2.0 copyright and notice
-above.
diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/MainActivity.java b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/MainActivity.java
index 6933e26..de553d2 100644
--- a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/MainActivity.java
+++ b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/MainActivity.java
@@ -35,7 +35,7 @@
import com.almothafar.simplebatterynotifier.service.BatteryHealthTracker;
import com.almothafar.simplebatterynotifier.service.PowerConnectionService;
import com.almothafar.simplebatterynotifier.service.SystemService;
-import com.almothafar.simplebatterynotifier.ui.widget.CircularProgressBar;
+import com.almothafar.simplebatterynotifier.ui.widget.HorseshoeProgressBar;
import java.util.List;
@@ -191,10 +191,10 @@ protected void onPostResume() {
startUpdateTimer();
- // Resume the pulse paused in onPause(); restarts only if the battery state still
- // warrants it (charging or critical).
- final CircularProgressBar progressBar = findViewById(R.id.batteryPercentage);
- progressBar.resumePulseAnimation();
+ // Resume the motion paused in onPause(); restarts only what the battery state still
+ // warrants (charging/discharging wave, full pulse, or critical breathing).
+ final HorseshoeProgressBar gauge = findViewById(R.id.batteryPercentage);
+ gauge.resumeAnimations();
}
/**
@@ -206,10 +206,10 @@ protected void onPause() {
super.onPause();
stopUpdateTimer();
- // The pulse is only auto-stopped when the view is destroyed (onDetachedFromWindow),
+ // Motion is only auto-stopped when the view is destroyed (onDetachedFromWindow),
// not on backgrounding, so pause it here for the same reason we stop the timer.
- final CircularProgressBar progressBar = findViewById(R.id.batteryPercentage);
- progressBar.pausePulseAnimation();
+ final HorseshoeProgressBar gauge = findViewById(R.id.batteryPercentage);
+ gauge.pauseAnimations();
}
/**
@@ -262,16 +262,15 @@ private void stopUpdateTimer() {
* Runs on the main thread via {@link #handler}.
*/
private void refreshBatteryUi() {
- final CircularProgressBar progressBar = findViewById(R.id.batteryPercentage);
+ final HorseshoeProgressBar gauge = findViewById(R.id.batteryPercentage);
fillBatteryInfo();
- progressBar.setProgress(batteryPercentage);
- progressBar.setTitle(batteryPercentage + "%");
- progressBar.setSubTitle(subTitle);
+ gauge.setLevel(batteryPercentage);
+ gauge.setTitle(batteryPercentage + "%");
+ gauge.setStatusText(subTitle);
- // Update charging animation based on battery status
+ // Drive the gauge motion: charging wave, full-on-charger idle pulse, or discharge wave.
if (nonNull(batteryDO)) {
- final boolean isCharging = batteryDO.getStatus() == BatteryManager.BATTERY_STATUS_CHARGING;
- progressBar.setCharging(isCharging);
+ gauge.setFlow(flowOf(batteryDO.getStatus()));
}
final FragmentManager fragmentManager = getSupportFragmentManager();
@@ -283,6 +282,21 @@ private void refreshBatteryUi() {
}
}
+ /**
+ * Map the OS battery status onto the gauge's three flow states: charging fills (wave forward),
+ * full-on-charger idles with a periodic pulse, anything else counts as draining.
+ */
+ private static HorseshoeProgressBar.Flow flowOf(final int batteryStatus) {
+ switch (batteryStatus) {
+ case BatteryManager.BATTERY_STATUS_CHARGING:
+ return HorseshoeProgressBar.Flow.FILLING;
+ case BatteryManager.BATTERY_STATUS_FULL:
+ return HorseshoeProgressBar.Flow.FULL;
+ default:
+ return HorseshoeProgressBar.Flow.DRAINING;
+ }
+ }
+
/**
* Initialize first values and animate the progress bar
*/
@@ -290,30 +304,16 @@ private void initializeFirstValues() {
fillBatteryInfo();
final SharedPreferences sharedPref = PreferenceManager.getDefaultSharedPreferences(this);
- final CircularProgressBar progressBar = findViewById(R.id.batteryPercentage);
- progressBar.setWarningLevel(sharedPref.getInt(getString(R.string._pref_key_warn_battery_level), 40));
- progressBar.setCriticalLevel(sharedPref.getInt(getString(R.string._pref_key_critical_battery_level), 20));
+ final HorseshoeProgressBar gauge = findViewById(R.id.batteryPercentage);
+ gauge.setThresholds(sharedPref.getInt(getString(R.string._pref_key_critical_battery_level), 20),
+ sharedPref.getInt(getString(R.string._pref_key_warn_battery_level), 40));
// Keep the in-fly slider in sync with values that may have changed in Settings.
syncThresholdSlider();
- progressBar.animateProgressTo(0, batteryPercentage, new CircularProgressBar.ProgressAnimationListener() {
-
- @Override
- public void onAnimationStart() {
- // Animation started
- }
-
- @Override
- public void onAnimationFinish() {
- // Animation finished
- }
-
- @Override
- public void onAnimationProgress(final int progress) {
- progressBar.setTitle(progress + "%");
- progressBar.setSubTitle(subTitle);
- }
+ gauge.animateLevelTo(batteryPercentage, progress -> {
+ gauge.setTitle(progress + "%");
+ gauge.setStatusText(subTitle);
});
}
@@ -360,10 +360,8 @@ public void onStopTrackingTouch(@NonNull final RangeSlider slider) {
.putInt(getString(R.string._pref_key_warn_battery_level), warning)
.apply();
- final CircularProgressBar progressBar = findViewById(R.id.batteryPercentage);
- progressBar.setCriticalLevel(critical);
- progressBar.setWarningLevel(warning);
- progressBar.invalidate();
+ final HorseshoeProgressBar gauge = findViewById(R.id.batteryPercentage);
+ gauge.setThresholds(critical, warning);
}
});
diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/CircularProgressBar.java b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/CircularProgressBar.java
deleted file mode 100644
index 5e200d8..0000000
--- a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/CircularProgressBar.java
+++ /dev/null
@@ -1,675 +0,0 @@
-/*
- * Simple Battery Notifier
- * Copyright (C) 2015-2026 Al-Mothafar Al-Hasan
- *
- * This file is derived from "circular_progress_bar" by Leon Cheng
- * (https://github.com/ylyc/circular_progress_bar), Copyright (C) 2013 Leon Cheng,
- * originally licensed under the Apache License, Version 2.0. This file has been
- * modified from the original (extends ProgressBar with a 270-degree gauge, the
- * cpb_* styleable, auto-fitting title/subtitle, pulse/glow animation, and more).
- *
- * The Apache-2.0-licensed original is used in compliance with that license; a
- * copy of the Apache License 2.0 is provided in licenses/Apache-2.0.txt and the
- * attribution is recorded in THIRD-PARTY-NOTICES.md. Apache 2.0 is compatible
- * with the GNU GPL v3, so the modifications and the combined work are licensed
- * under the GNU GPL v3 (see the project LICENSE.md).
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see
- * The pulse is purely decorative, so there is no reason to keep it invalidating on the - * main thread while nobody can see it. Counterpart to {@link #resumePulseAnimation()}, - * called from the activity's {@code onPause()}. - */ - public void pausePulseAnimation() { - stopPulseAnimation(); - } - - /** - * Resume the pulse animation when the host activity returns to the foreground. - *
- * Restarts the pulse only if the current battery state still calls for it (charging or - * critical); otherwise it stays off. Counterpart to {@link #pausePulseAnimation()}, - * called from the activity's {@code onResume()}. - */ - public void resumePulseAnimation() { - updateAnimationState(); - } - - /** - * Draw the circular progress bar - * - * @param canvas The canvas to draw on - */ - @Override - protected synchronized void onDraw(@NonNull final Canvas canvas) { - final int progress = getProgress(); - final float scale = getMax() > 0 ? (float) progress / getMax() * SWEEP_ANGLE : 0; - - // Apply pulse scale if charging or critical - final boolean shouldPulse = isCharging || isCritical; - if (shouldPulse && currentPulseScale != 1.0f) { - canvas.save(); - final float pivotX = getMeasuredWidth() / 2f; - final float pivotY = getMeasuredHeight() / 2f; - canvas.scale(currentPulseScale, currentPulseScale, pivotX, pivotY); - } - - // Draw a background track - canvas.drawArc(circleBounds, START_ANGLE, SWEEP_ANGLE, false, backgroundColorPaint); - - // Determine progress color based on battery level (colors resolved once in initialize()). - final int progressColor; - if (progress >= warningLevel) { - progressColor = progressDefaultColor; - } else if (progress > criticalLevel) { - progressColor = progressWarningColor; - } else { - progressColor = progressAlertColor; - } - progressColorPaint.setColor(progressColor); - - // Draw subtle glow layer when charging or critical - if (shouldPulse && currentGlowAlpha > 0) { - glowPaint.setColor(progressColor); - glowPaint.setAlpha(currentGlowAlpha); - glowPaint.setStyle(Paint.Style.STROKE); - glowPaint.setStrokeWidth(progressColorPaint.getStrokeWidth() + glowExtraStrokePx); - glowPaint.setStrokeCap(Paint.Cap.ROUND); - glowPaint.setAntiAlias(true); - canvas.drawArc(circleBounds, START_ANGLE, scale, false, glowPaint); - } - - // Draw progress arc with shadow for depth - if (hasShadow) { - progressColorPaint.setShadowLayer(12f, 2, 6, shadowColor); - } else { - progressColorPaint.clearShadowLayer(); - } - canvas.drawArc(circleBounds, START_ANGLE, scale, false, progressColorPaint); - - // Draw title and subtitle - if (nonNull(title) && !title.isEmpty()) { - // Auto-fit the title so wide values (e.g. "100%") don't overflow the arc on a small gauge. - titlePaint.setTextSize(baseTitleTextSizePx); - final float maxTitleWidth = circleBounds.width() * 0.68f; - final float rawTitleWidth = titlePaint.measureText(title); - if (rawTitleWidth > maxTitleWidth) { - titlePaint.setTextSize(baseTitleTextSizePx * maxTitleWidth / rawTitleWidth); - } - - float xPos = (int) (getMeasuredWidth() / 2f - titlePaint.measureText(title) / 2); - float yPos = getMeasuredHeight() / 2f; - - final float titleHeight = Math.abs(titlePaint.descent() + titlePaint.ascent()); - if (isNull(subTitle) || subTitle.isEmpty()) { - yPos += titleHeight / 2f; - } - canvas.drawText(title, xPos, yPos, titlePaint); - - // Auto-fit the subtitle inside the ring. The 270° arc narrows below centre, so shrink long - // labels like "Not Charging" to the actual horizontal chord available at the subtitle's - // offset (titleHeight below centre), within the ring thickness. See issue #91. - subtitlePaint.setTextSize(baseSubtitleTextSizePx); - final float innerRadius = circleBounds.width() / 2f - progressColorPaint.getStrokeWidth() / 2f; - final float halfChord = titleHeight < innerRadius - ? (float) Math.sqrt(innerRadius * innerRadius - titleHeight * titleHeight) - : 0f; - final float maxSubtitleWidth = 2f * halfChord * 0.9f; - final float rawSubtitleWidth = subtitlePaint.measureText(subTitle); - if (maxSubtitleWidth > 0f && rawSubtitleWidth > maxSubtitleWidth) { - subtitlePaint.setTextSize(baseSubtitleTextSizePx * maxSubtitleWidth / rawSubtitleWidth); - } - - yPos += titleHeight; - xPos = (int) (getMeasuredWidth() / 2f - subtitlePaint.measureText(subTitle) / 2); - - canvas.drawText(subTitle, xPos, yPos, subtitlePaint); - } - - // Restore canvas state if we applied pulse scale - if (shouldPulse && currentPulseScale != 1.0f) { - canvas.restore(); - } - - super.onDraw(canvas); - } - - /** - * Measure the view dimensions - * - * @param widthMeasureSpec Width measure specification - * @param heightMeasureSpec Height measure specification - */ - @Override - protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { - final int height = getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec); - final int width = GeneralHelper.dpToPixel(getResources(), getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec)); - final int min = Math.min(width, height); - final int strokeWidthPx = GeneralHelper.dpToPixel(getResources(), STROKE_WIDTH); - - setMeasuredDimension(min + 2 * strokeWidthPx, min + 2 * strokeWidthPx); - - // Set bounds with proper inset for stroke (using float for RectF) - final float size = min + strokeWidthPx; - circleBounds.set((float) strokeWidthPx, (float) strokeWidthPx, size, size); - } - - /** - * Set the progress value and trigger a redraw - *
- * Also updates the content description for accessibility support, - * allowing screen readers (TalkBack) to announce the current battery percentage. - * - * @param progress The progress value - */ - @Override - public synchronized void setProgress(final int progress) { - super.setProgress(progress); - - // Update content description for accessibility (screen readers) - updateAccessibilityDescription(progress); - - // Update animation state based on new progress - updateAnimationState(); - - // Force an update to redraw the progress bar - invalidate(); - } - - /** - * Clean up animator when view is detached - */ - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - stopPulseAnimation(); - } - - /** - * Initialize the view with custom attributes - * - * @param attrs Attribute set from XML - * @param style Style attribute - */ - private void initialize(final AttributeSet attrs, final int style) { - // Enable software layer so that shadow shows up properly for lines and arcs - setLayerType(View.LAYER_TYPE_SOFTWARE, null); - - // Enable accessibility support for screen readers (TalkBack) - setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); - - try (final TypedArray styledAttributes = getContext().obtainStyledAttributes(attrs, R.styleable.CircularProgressBar, style, 0)) { - final Resources res = getResources(); - - this.hasShadow = styledAttributes.getBoolean(R.styleable.CircularProgressBar_cpb_hasShadow, true); - - int color = styledAttributes.getColor(R.styleable.CircularProgressBar_cpb_progressColor, - GeneralHelper.getColor(res, R.color.circular_progress_default_progress)); - progressColorPaint.setColor(color); - - color = styledAttributes.getColor(R.styleable.CircularProgressBar_cpb_backgroundColor, - GeneralHelper.getColor(res, R.color.circular_progress_default_background)); - backgroundColorPaint.setColor(color); - - color = styledAttributes.getColor(R.styleable.CircularProgressBar_cpb_titleColor, - GeneralHelper.getColor(res, R.color.circular_progress_default_title)); - titlePaint.setColor(color); - - color = styledAttributes.getColor(R.styleable.CircularProgressBar_cpb_subtitleColor, - GeneralHelper.getColor(res, R.color.circular_progress_default_subtitle)); - subtitlePaint.setColor(color); - - // Resolve the progress-state colors and glow stroke once here rather than per-frame in onDraw(). - progressDefaultColor = GeneralHelper.getColor(res, R.color.circular_progress_default_progress); - progressWarningColor = GeneralHelper.getColor(res, R.color.circular_progress_default_progress_warning); - progressAlertColor = GeneralHelper.getColor(res, R.color.circular_progress_default_progress_alert); - glowExtraStrokePx = GeneralHelper.dpToPixel(res, 8); - - final String titleAttr = styledAttributes.getString(R.styleable.CircularProgressBar_cpb_title); - if (nonNull(titleAttr)) { - title = titleAttr; - } - - final String subtitleAttr = styledAttributes.getString(R.styleable.CircularProgressBar_cpb_subtitle); - if (nonNull(subtitleAttr)) { - subTitle = subtitleAttr; - } - - final int strokeWidth = styledAttributes.getInt(R.styleable.CircularProgressBar_cpb_strokeWidth, STROKE_WIDTH); - final int titleTextSize = styledAttributes.getInt(R.styleable.CircularProgressBar_cpb_titleTextSize, DEFAULT_TITLE_SIZE); - final int subtitleTextSize = styledAttributes.getInt(R.styleable.CircularProgressBar_cpb_subtitleTextSize, DEFAULT_SUBTITLE_SIZE); - - // No need to manually recycle - try-with-resources handles it automatically - - // Modern progress arc with smooth rounded ends - progressColorPaint.setAntiAlias(true); - progressColorPaint.setStyle(Paint.Style.STROKE); - progressColorPaint.setStrokeWidth(GeneralHelper.dpToPixel(res, strokeWidth)); - progressColorPaint.setStrokeCap(Paint.Cap.ROUND); // Smooth rounded caps - - // Background arc - backgroundColorPaint.setAntiAlias(true); - backgroundColorPaint.setStyle(Paint.Style.STROKE); - backgroundColorPaint.setStrokeWidth(GeneralHelper.dpToPixel(res, strokeWidth + STROKE_PADDING)); - backgroundColorPaint.setStrokeCap(Paint.Cap.ROUND); // Smooth rounded caps - - // Percentage text - bold and clear - titlePaint.setTextSize(GeneralHelper.dpToPixel(res, titleTextSize)); - baseTitleTextSizePx = titlePaint.getTextSize(); - titlePaint.setStyle(Style.FILL); - titlePaint.setAntiAlias(true); - titlePaint.setTypeface(Typeface.create("sans-serif-medium", Typeface.BOLD)); - titlePaint.setShadowLayer(3f, 0, 2, Color.argb(50, 0, 0, 0)); // Subtle shadow - - // Status text - subtitlePaint.setTextSize(GeneralHelper.dpToPixel(res, subtitleTextSize)); - baseSubtitleTextSizePx = subtitlePaint.getTextSize(); - subtitlePaint.setStyle(Style.FILL); - subtitlePaint.setAntiAlias(true); - subtitlePaint.setTypeface(Typeface.create("sans-serif", Typeface.NORMAL)); - } - } - - /** - * Update the content description for accessibility - *
- * This allows screen readers like TalkBack to announce the battery percentage
- * to vision-impaired users. The description is updated whenever the progress changes.
- *
- * @param progress The current progress value
- */
- private void updateAccessibilityDescription(final int progress) {
- final String description = getContext().getString(R.string.battery_progress_description, progress);
- setContentDescription(description);
- }
-
- /**
- * Listener interface for progress animation callbacks
- */
- public interface ProgressAnimationListener {
- /**
- * Called when animation starts
- */
- void onAnimationStart();
-
- /**
- * Called when animation finishes
- */
- void onAnimationFinish();
-
- /**
- * Called during animation progress updates
- *
- * @param progress Current progress value
- */
- void onAnimationProgress(int progress);
- }
-}
diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/HorseshoeProgressBar.java b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/HorseshoeProgressBar.java
new file mode 100644
index 0000000..b777f2f
--- /dev/null
+++ b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/HorseshoeProgressBar.java
@@ -0,0 +1,621 @@
+/*
+ * HorseshoeProgressBar - an animated horseshoe-shaped progress gauge for Android.
+ * Part of Simple Battery Notifier
+ * The ring color follows two thresholds ({@link #setThresholds(int, int)}): normal above the
+ * warning level, warning color between the two, alert color at or below critical. Decorative
+ * motion follows the flow state ({@link #setFlow(Flow)}):
+ *
+ * Performance: on Android 9+ the gauge renders fully hardware-accelerated (arc shadows are
+ * GPU-capable there); only Android 8 falls back to a software layer. Animation frames that would
+ * not change a visible pixel are skipped, and the host should call {@link #pauseAnimations()} /
+ * {@link #resumeAnimations()} from its pause/resume so nothing runs while backgrounded.
+ *
+ * Reuse: the class is self-contained — to use it in another project, copy this file plus the
+ * {@code HorseshoeProgressBar} declare-styleable block from {@code attrs.xml}. All colors, text
+ * sizes, and the accessibility description format are attributes with built-in defaults; nothing
+ * else in this app is referenced. Written from scratch for this project (issue #144), GPLv3 as
+ * headed above.
+ */
+public class HorseshoeProgressBar extends View {
+
+ /** The ring leaves a 90° opening centered at the bottom: the arc runs 135° → 405°. */
+ private static final float GAP_DEGREES = 90f;
+ private static final float ARC_START = 90f + GAP_DEGREES / 2f;
+ private static final float ARC_SWEEP = 360f - GAP_DEGREES;
+
+ private static final int MAX_LEVEL = 100;
+ private static final long LEVEL_ANIMATION_MS = 1000;
+
+ /** Wave: width of the traveling highlight band and one full start→end trip per state. */
+ private static final float WAVE_BAND_DEGREES = 42f;
+ private static final long WAVE_TRIP_FILLING_MS = 2400;
+ private static final long WAVE_TRIP_DRAINING_MS = 8000;
+ private static final int WAVE_HIGHLIGHT_COLOR = 0x8CFFFFFF;
+
+ /** Breathing: gentle shrink plus a glow that follows the breath; critical is faster and brighter. */
+ private static final long BREATH_CYCLE_FILLING_MS = 2000;
+ private static final long BREATH_CYCLE_CRITICAL_MS = 1500;
+ private static final float BREATH_MIN_SCALE = 0.95f;
+ private static final int BREATH_GLOW_ALPHA_FILLING = 40;
+ private static final int BREATH_GLOW_ALPHA_CRITICAL = 60;
+ private static final float GLOW_EXTRA_STROKE_DP = 8f;
+
+ /** Full: one gentle pulse per period; the rest of the period draws nothing. */
+ private static final long IDLE_PULSE_PERIOD_MS = 3000;
+ private static final float IDLE_PULSE_ACTIVE_FRACTION = 0.35f;
+ private static final int IDLE_PULSE_GLOW_ALPHA = 40;
+
+ /** Built-in appearance defaults, all overridable via the gauge* attributes. */
+ private static final int DEFAULT_LEVEL_COLOR = 0xFF2E9CB8;
+ private static final int DEFAULT_WARNING_COLOR = 0xFFF3A712;
+ private static final int DEFAULT_ALERT_COLOR = 0xFFE23A2E;
+ private static final int DEFAULT_TRACK_COLOR = 0x338A8A8A;
+ private static final int DEFAULT_TEXT_COLOR = 0xFFFFFFFF;
+ private static final String DEFAULT_LEVEL_DESCRIPTION = "Level at %1$d percent";
+
+ /** The track ring is drawn slightly wider than the level ring, framing it. */
+ private static final float TRACK_EXTRA_STROKE_DP = 4f;
+
+ /** Title may span at most this fraction of the ring's inner width before shrinking. */
+ private static final float TITLE_MAX_WIDTH_FRACTION = 0.68f;
+ private static final float STATUS_MAX_CHORD_FRACTION = 0.9f;
+
+ /** Which way the measured value is moving, as far as the gauge cares about it. */
+ public enum Flow { FILLING, FULL, DRAINING }
+
+ // What the gauge shows.
+ private int level;
+ private int criticalLevel = 20;
+ private int warningLevel = 40;
+ private Flow flow = Flow.DRAINING;
+ private boolean waveEnabled = true;
+ private String title = "";
+ private String statusText = "";
+ private String levelDescriptionFormat = DEFAULT_LEVEL_DESCRIPTION;
+
+ // How the gauge looks (resolved once from attrs/theme).
+ private float strokeWidthPx;
+ private float titleBaseTextSizePx;
+ private float statusBaseTextSizePx;
+ private boolean showShadow;
+ private int normalColor;
+ private int warningColor;
+ private int alertColor;
+
+ private final Paint trackPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint levelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint wavePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint glowPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint titlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint statusPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+ // Geometry, derived from the view size in onSizeChanged().
+ private final RectF ring = new RectF();
+ private final Matrix waveRotation = new Matrix();
+ private SweepGradient waveGradient;
+
+ // Live motion state.
+ private ValueAnimator motionAnimator;
+ private ValueAnimator levelAnimator;
+ private float waveTravel; // 0..1 position of the highlight along its trip
+ private float breathScale = 1f;
+ private int glowAlpha;
+
+ private enum Motion { NONE, WAVE_FORWARD, WAVE_REVERSE, BREATH_FILLING, BREATH_CRITICAL, PULSE_IDLE }
+
+ private Motion motion = Motion.NONE;
+
+ public HorseshoeProgressBar(final Context context) {
+ this(context, null);
+ }
+
+ public HorseshoeProgressBar(final Context context, final AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public HorseshoeProgressBar(final Context context, final AttributeSet attrs, final int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ // Hardware canvases only learned to draw arc shadows in Android 9 (Skia renderer);
+ // on Android 8 fall back to a software layer so the shadow still shows up.
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
+ setLayerType(View.LAYER_TYPE_SOFTWARE, null);
+ }
+ setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
+ resolveAppearance(attrs, defStyleAttr);
+ announceLevel();
+ }
+
+ // ------------------------------------------------------------------ public API
+
+ /** Show the given level (0–100) immediately. */
+ public void setLevel(final int level) {
+ this.level = clampLevel(level);
+ announceLevel();
+ refreshMotion();
+ invalidate();
+ }
+
+ public int getLevel() {
+ return level;
+ }
+
+ /** The big centered text, normally the percentage (e.g. "80%"). */
+ public void setTitle(final String title) {
+ this.title = nonNull(title) ? title : "";
+ invalidate();
+ }
+
+ /** The smaller line under the title, normally a status label. */
+ public void setStatusText(final String statusText) {
+ this.statusText = nonNull(statusText) ? statusText : "";
+ invalidate();
+ }
+
+ /** Update both alert thresholds at once; the ring color and motion re-evaluate immediately. */
+ public void setThresholds(final int critical, final int warning) {
+ this.criticalLevel = critical;
+ this.warningLevel = warning;
+ refreshMotion();
+ invalidate();
+ }
+
+ /** Tell the gauge which way the value is moving; the motion (wave/pulse/breath) follows it. */
+ public void setFlow(final Flow flow) {
+ if (this.flow == flow) {
+ return;
+ }
+ this.flow = flow;
+ refreshMotion();
+ invalidate();
+ }
+
+ /**
+ * Waves are on by default; battery-sensitive hosts can turn them off, in which case filling
+ * falls back to a gentle breathing pulse and draining goes completely still.
+ */
+ public void setWaveEnabled(final boolean waveEnabled) {
+ if (this.waveEnabled == waveEnabled) {
+ return;
+ }
+ this.waveEnabled = waveEnabled;
+ refreshMotion();
+ invalidate();
+ }
+
+ /**
+ * Animate the ring from empty up to {@code target}, invoking {@code perStep} with each
+ * intermediate level so the caller can keep companion text in sync.
+ */
+ public void animateLevelTo(final int target, final IntConsumer perStep) {
+ if (nonNull(levelAnimator)) {
+ levelAnimator.cancel();
+ }
+ levelAnimator = ValueAnimator.ofInt(0, clampLevel(target));
+ levelAnimator.setDuration(LEVEL_ANIMATION_MS);
+ levelAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
+ levelAnimator.addUpdateListener(animation -> {
+ setLevel((int) animation.getAnimatedValue());
+ if (nonNull(perStep)) {
+ perStep.accept(level);
+ }
+ });
+ levelAnimator.start();
+ }
+
+ /**
+ * Stop all decorative motion while the host is backgrounded, so the gauge does not keep
+ * redrawing (and burning battery) when nobody can see it.
+ */
+ public void pauseAnimations() {
+ stopMotion();
+ }
+
+ /** Restart whatever motion the current state calls for; counterpart of pause. */
+ public void resumeAnimations() {
+ refreshMotion();
+ }
+
+ // ------------------------------------------------------------------ measurement & geometry
+
+ /**
+ * The layout fixes one side (usually the height); the gauge takes that as the ring diameter
+ * and sizes itself square with room for the stroke to sit fully outside the ring bounds.
+ */
+ @Override
+ protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
+ final int hSize = MeasureSpec.getSize(heightMeasureSpec);
+ final int wSize = MeasureSpec.getSize(widthMeasureSpec);
+ final int diameter = MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.UNSPECIFIED
+ ? hSize
+ : Math.max(wSize, getSuggestedMinimumHeight());
+ final int side = diameter + Math.round(2 * strokeWidthPx);
+ setMeasuredDimension(side, side);
+ }
+
+ @Override
+ protected void onSizeChanged(final int w, final int h, final int oldW, final int oldH) {
+ super.onSizeChanged(w, h, oldW, oldH);
+ ring.set(strokeWidthPx, strokeWidthPx, w - strokeWidthPx, h - strokeWidthPx);
+
+ // The wave shader is anchored to the view center, so it must be rebuilt on resize.
+ waveGradient = new SweepGradient(w / 2f, h / 2f,
+ new int[]{Color.TRANSPARENT, WAVE_HIGHLIGHT_COLOR, Color.TRANSPARENT},
+ new float[]{0f, WAVE_BAND_DEGREES / 720f, WAVE_BAND_DEGREES / 360f});
+ wavePaint.setShader(waveGradient);
+ }
+
+ // ------------------------------------------------------------------ drawing
+
+ @Override
+ protected void onDraw(@NonNull final Canvas canvas) {
+ super.onDraw(canvas);
+
+ final boolean breathing = isBreathing() && breathScale != 1f;
+ if (breathing) {
+ canvas.save();
+ canvas.scale(breathScale, breathScale, getWidth() / 2f, getHeight() / 2f);
+ }
+
+ canvas.drawArc(ring, ARC_START, ARC_SWEEP, false, trackPaint);
+
+ final float litSweep = ARC_SWEEP * level / MAX_LEVEL;
+ final int ringColor = colorForLevel();
+ levelPaint.setColor(ringColor);
+ if (showShadow) {
+ levelPaint.setShadowLayer(12f, 2f, 6f, Color.argb(180, 0, 0, 0));
+ } else {
+ levelPaint.clearShadowLayer();
+ }
+
+ if (isBreathing() && glowAlpha > 0) {
+ glowPaint.setColor(ringColor);
+ glowPaint.setAlpha(glowAlpha);
+ canvas.drawArc(ring, ARC_START, litSweep, false, glowPaint);
+ }
+
+ canvas.drawArc(ring, ARC_START, litSweep, false, levelPaint);
+
+ if (isWaving() && litSweep > 0f) {
+ drawWave(canvas, litSweep);
+ }
+
+ drawCenterText(canvas);
+
+ if (breathing) {
+ canvas.restore();
+ }
+ }
+
+ /**
+ * Slide the highlight band along the lit arc. The band's angular position comes from rotating
+ * the sweep gradient; drawing is clipped to the lit sweep so the wave never runs past the tip.
+ * The travel range overshoots by one band width on each side so the band fully enters and exits.
+ */
+ private void drawWave(final Canvas canvas, final float litSweep) {
+ final float travelRange = litSweep + 2 * WAVE_BAND_DEGREES;
+ final float bandAngle = ARC_START - WAVE_BAND_DEGREES + waveTravel * travelRange;
+ waveRotation.setRotate(bandAngle, getWidth() / 2f, getHeight() / 2f);
+ waveGradient.setLocalMatrix(waveRotation);
+ canvas.drawArc(ring, ARC_START, litSweep, false, wavePaint);
+ }
+
+ private void drawCenterText(final Canvas canvas) {
+ if (title.isEmpty()) {
+ return;
+ }
+ final float centerX = getWidth() / 2f;
+ final float centerY = getHeight() / 2f;
+
+ // Shrink the title if a wide value (like "100%") would overflow the ring's inner width.
+ titlePaint.setTextSize(titleBaseTextSizePx);
+ final float titleLimit = ring.width() * TITLE_MAX_WIDTH_FRACTION;
+ final float titleWidth = titlePaint.measureText(title);
+ if (titleWidth > titleLimit) {
+ titlePaint.setTextSize(titleBaseTextSizePx * titleLimit / titleWidth);
+ }
+ final float titleHeight = Math.abs(titlePaint.descent() + titlePaint.ascent());
+ final float titleBaseline = statusText.isEmpty() ? centerY + titleHeight / 2f : centerY;
+ canvas.drawText(title, centerX - titlePaint.measureText(title) / 2f, titleBaseline, titlePaint);
+
+ if (statusText.isEmpty()) {
+ return;
+ }
+
+ // The ring narrows below center, so fit the status line to the chord actually available
+ // at its vertical offset (one title-height below center), inside the ring thickness.
+ statusPaint.setTextSize(statusBaseTextSizePx);
+ final float innerRadius = ring.width() / 2f - strokeWidthPx / 2f;
+ final float chordHalf = titleHeight < innerRadius
+ ? (float) Math.sqrt(innerRadius * innerRadius - titleHeight * titleHeight)
+ : 0f;
+ final float statusLimit = 2f * chordHalf * STATUS_MAX_CHORD_FRACTION;
+ final float statusWidth = statusPaint.measureText(statusText);
+ if (statusLimit > 0f && statusWidth > statusLimit) {
+ statusPaint.setTextSize(statusBaseTextSizePx * statusLimit / statusWidth);
+ }
+ canvas.drawText(statusText, centerX - statusPaint.measureText(statusText) / 2f,
+ titleBaseline + titleHeight, statusPaint);
+ }
+
+ // ------------------------------------------------------------------ motion control
+
+ /**
+ * Pick the motion the current state calls for and (re)start its animator only when the kind
+ * of motion actually changed, so ongoing loops are not restarted on every refresh tick.
+ */
+ private void refreshMotion() {
+ final Motion wanted = wantedMotion();
+ if (wanted == motion && nonNull(motionAnimator) && motionAnimator.isRunning()) {
+ return;
+ }
+ stopMotion();
+ motion = wanted;
+ switch (wanted) {
+ case WAVE_FORWARD:
+ startWave(WAVE_TRIP_FILLING_MS, false);
+ break;
+ case WAVE_REVERSE:
+ startWave(WAVE_TRIP_DRAINING_MS, true);
+ break;
+ case BREATH_FILLING:
+ startBreath(BREATH_CYCLE_FILLING_MS, BREATH_GLOW_ALPHA_FILLING);
+ break;
+ case BREATH_CRITICAL:
+ startBreath(BREATH_CYCLE_CRITICAL_MS, BREATH_GLOW_ALPHA_CRITICAL);
+ break;
+ case PULSE_IDLE:
+ startIdlePulse();
+ break;
+ case NONE:
+ break;
+ }
+ }
+
+ private Motion wantedMotion() {
+ if (!isAttachedToWindow() || level == 0) {
+ return Motion.NONE;
+ }
+ if (flow == Flow.FILLING) {
+ return waveEnabled ? Motion.WAVE_FORWARD : Motion.BREATH_FILLING;
+ }
+ if (flow == Flow.FULL) {
+ return Motion.PULSE_IDLE;
+ }
+ if (level <= criticalLevel) {
+ return Motion.BREATH_CRITICAL;
+ }
+ return waveEnabled ? Motion.WAVE_REVERSE : Motion.NONE;
+ }
+
+ private void startWave(final long tripMillis, final boolean reversed) {
+ motionAnimator = ValueAnimator.ofFloat(0f, 1f);
+ motionAnimator.setDuration(tripMillis);
+ motionAnimator.setInterpolator(new LinearInterpolator());
+ motionAnimator.setRepeatCount(ValueAnimator.INFINITE);
+ motionAnimator.addUpdateListener(animation -> {
+ // Quantize the travel so animator ticks that would move the band less than a visible
+ // step are skipped instead of triggering a redraw for nothing.
+ final float quantized = Math.round((float) animation.getAnimatedValue() * 512f) / 512f;
+ final float travel = reversed ? 1f - quantized : quantized;
+ if (travel == waveTravel) {
+ return;
+ }
+ waveTravel = travel;
+ invalidate();
+ });
+ motionAnimator.start();
+ }
+
+ /**
+ * One soft pulse per {@link #IDLE_PULSE_PERIOD_MS}: the breath happens in the first
+ * {@link #IDLE_PULSE_ACTIVE_FRACTION} of the period and the remainder draws nothing at all —
+ * the update listener bails out while the visuals are at rest, so a full gauge sitting idle
+ * costs almost nothing to display.
+ */
+ private void startIdlePulse() {
+ motionAnimator = ValueAnimator.ofFloat(0f, 1f);
+ motionAnimator.setDuration(IDLE_PULSE_PERIOD_MS);
+ motionAnimator.setInterpolator(new LinearInterpolator());
+ motionAnimator.setRepeatCount(ValueAnimator.INFINITE);
+ motionAnimator.addUpdateListener(animation -> {
+ final float period = (float) animation.getAnimatedValue();
+ final float depth = period < IDLE_PULSE_ACTIVE_FRACTION
+ ? (float) Math.sin(Math.PI * period / IDLE_PULSE_ACTIVE_FRACTION)
+ : 0f;
+ final float scale = 1f - (1f - BREATH_MIN_SCALE) * depth;
+ final int glow = (int) (depth * IDLE_PULSE_GLOW_ALPHA);
+ if (scale == breathScale && glow == glowAlpha) {
+ return;
+ }
+ breathScale = scale;
+ glowAlpha = glow;
+ invalidate();
+ });
+ motionAnimator.start();
+ }
+
+ private void startBreath(final long cycleMillis, final int maxGlowAlpha) {
+ motionAnimator = ValueAnimator.ofFloat(BREATH_MIN_SCALE, 1f);
+ motionAnimator.setDuration(cycleMillis);
+ motionAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
+ motionAnimator.setRepeatCount(ValueAnimator.INFINITE);
+ motionAnimator.setRepeatMode(ValueAnimator.REVERSE);
+ motionAnimator.addUpdateListener(animation -> {
+ breathScale = (float) animation.getAnimatedValue();
+ final float depth = (breathScale - BREATH_MIN_SCALE) / (1f - BREATH_MIN_SCALE);
+ glowAlpha = (int) (depth * maxGlowAlpha);
+ invalidate();
+ });
+ motionAnimator.start();
+ }
+
+ private void stopMotion() {
+ if (nonNull(motionAnimator)) {
+ motionAnimator.cancel();
+ motionAnimator = null;
+ }
+ motion = Motion.NONE;
+ breathScale = 1f;
+ glowAlpha = 0;
+ invalidate();
+ }
+
+ private boolean isWaving() {
+ return motion == Motion.WAVE_FORWARD || motion == Motion.WAVE_REVERSE;
+ }
+
+ private boolean isBreathing() {
+ return motion == Motion.BREATH_FILLING || motion == Motion.BREATH_CRITICAL || motion == Motion.PULSE_IDLE;
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ refreshMotion();
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ stopMotion();
+ if (nonNull(levelAnimator)) {
+ levelAnimator.cancel();
+ levelAnimator = null;
+ }
+ }
+
+ // ------------------------------------------------------------------ appearance & helpers
+
+ private void resolveAppearance(final AttributeSet attrs, final int defStyleAttr) {
+ final Context context = getContext();
+ final float density = getResources().getDisplayMetrics().density;
+
+ final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.HorseshoeProgressBar, defStyleAttr, 0);
+ try {
+ strokeWidthPx = a.getDimension(R.styleable.HorseshoeProgressBar_gaugeStrokeWidth, 25 * density);
+ titleBaseTextSizePx = a.getDimension(R.styleable.HorseshoeProgressBar_gaugeTitleTextSize, 64 * density);
+ statusBaseTextSizePx = a.getDimension(R.styleable.HorseshoeProgressBar_gaugeStatusTextSize, 20 * density);
+ showShadow = a.getBoolean(R.styleable.HorseshoeProgressBar_gaugeShowShadow, true);
+
+ normalColor = a.getColor(R.styleable.HorseshoeProgressBar_gaugeLevelColor, DEFAULT_LEVEL_COLOR);
+ warningColor = a.getColor(R.styleable.HorseshoeProgressBar_gaugeWarningColor, DEFAULT_WARNING_COLOR);
+ alertColor = a.getColor(R.styleable.HorseshoeProgressBar_gaugeAlertColor, DEFAULT_ALERT_COLOR);
+ trackPaint.setColor(a.getColor(R.styleable.HorseshoeProgressBar_gaugeTrackColor, DEFAULT_TRACK_COLOR));
+ titlePaint.setColor(a.getColor(R.styleable.HorseshoeProgressBar_gaugeTitleColor, DEFAULT_TEXT_COLOR));
+ statusPaint.setColor(a.getColor(R.styleable.HorseshoeProgressBar_gaugeStatusColor, DEFAULT_TEXT_COLOR));
+
+ // Localizable "Level at %1$d percent" template for screen readers.
+ final String description = a.getString(R.styleable.HorseshoeProgressBar_gaugeLevelDescription);
+ levelDescriptionFormat = nonNull(description) && !description.isEmpty() ? description : DEFAULT_LEVEL_DESCRIPTION;
+
+ // Preview text so the gauge is meaningful in the layout editor.
+ title = orEmpty(a.getString(R.styleable.HorseshoeProgressBar_gaugeTitle));
+ statusText = orEmpty(a.getString(R.styleable.HorseshoeProgressBar_gaugeStatusText));
+ } finally {
+ a.recycle();
+ }
+
+ trackPaint.setStyle(Paint.Style.STROKE);
+ trackPaint.setStrokeWidth(strokeWidthPx + TRACK_EXTRA_STROKE_DP * density);
+ trackPaint.setStrokeCap(Paint.Cap.ROUND);
+
+ levelPaint.setStyle(Paint.Style.STROKE);
+ levelPaint.setStrokeWidth(strokeWidthPx);
+ levelPaint.setStrokeCap(Paint.Cap.ROUND);
+
+ wavePaint.setStyle(Paint.Style.STROKE);
+ wavePaint.setStrokeWidth(strokeWidthPx);
+ wavePaint.setStrokeCap(Paint.Cap.ROUND);
+
+ glowPaint.setStyle(Paint.Style.STROKE);
+ glowPaint.setStrokeWidth(strokeWidthPx + GLOW_EXTRA_STROKE_DP * density);
+ glowPaint.setStrokeCap(Paint.Cap.ROUND);
+
+ titlePaint.setTypeface(Typeface.create("sans-serif-medium", Typeface.BOLD));
+ titlePaint.setShadowLayer(3f, 0f, 2f, Color.argb(50, 0, 0, 0));
+ statusPaint.setTypeface(Typeface.create("sans-serif", Typeface.NORMAL));
+ }
+
+ private int colorForLevel() {
+ if (level >= warningLevel) {
+ return normalColor;
+ }
+ if (level > criticalLevel) {
+ return warningColor;
+ }
+ return alertColor;
+ }
+
+ private void announceLevel() {
+ setContentDescription(String.format(Locale.getDefault(), levelDescriptionFormat, level));
+ }
+
+ private static int clampLevel(final int value) {
+ return Math.max(0, Math.min(MAX_LEVEL, value));
+ }
+
+ private static String orEmpty(final String value) {
+ return nonNull(value) ? value : "";
+ }
+}
diff --git a/app/src/main/res/layout-land/activity_main.xml b/app/src/main/res/layout-land/activity_main.xml
index 23d16e1..19ff9af 100644
--- a/app/src/main/res/layout-land/activity_main.xml
+++ b/app/src/main/res/layout-land/activity_main.xml
@@ -1,5 +1,4 @@
+ *
+ * Waves can be turned off wholesale with {@link #setWaveEnabled(boolean)} for battery-sensitive
+ * hosts; filling then falls back to a gentle breathing pulse and draining goes still.
+ *