From 16bad4d7c9843eb052578fc461e75b28ba8b57a6 Mon Sep 17 00:00:00 2001 From: Al-Mothafar Al-Hasan Date: Mon, 13 Jul 2026 09:14:58 +0300 Subject: [PATCH 1/5] Rewrite the battery gauge from scratch; add directional charge wave (#144) Replace the third-party-derived CircularProgressBar (ylyc/circular_progress_bar, see #141) with BatteryGaugeView, written from scratch for this project, so the gauge is original work under the project's GPLv3 and the Apache attribution artifacts can go away. New widget, same look: a View (not ProgressBar) drawing the same 270-degree horseshoe (gap parameterized instead of magic start/sweep), same colors, stroke, shadow, auto-fitting title/status text, threshold-driven ring color, and the critical breathing pulse. Fresh architecture: instance thresholds set in one call, dimension-typed gauge* attributes replacing the cpb_* integers, geometry computed in onSizeChanged, one motion animator driven by a small state enum, and a leaner API (setLevel/setThresholds/animateLevelTo with an IntConsumer step callback instead of a 3-method listener). New behavior: a soft highlight wave travels along the lit arc - start to tip while charging (2.4s trip), slower tip to start while discharging (8s trip). Critical (not charging) keeps the breathing pulse instead. All motion pauses when the activity is backgrounded (same hooks as before, renamed pauseAnimations/resumeAnimations). Removed: CircularProgressBar.java, THIRD-PARTY-NOTICES.md, licenses/Apache-2.0.txt, the README acknowledgement, and the unused Widget.ProgressBar.Holo.CircularProgressBar style. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 - THIRD-PARTY-NOTICES.md | 23 - .../ui/MainActivity.java | 60 +- .../ui/widget/BatteryGaugeView.java | 520 ++++++++++++++ .../ui/widget/CircularProgressBar.java | 675 ------------------ .../main/res/layout-land/activity_main.xml | 9 +- app/src/main/res/layout/activity_main.xml | 8 +- app/src/main/res/values/attrs.xml | 21 +- app/src/main/res/values/styles.xml | 22 +- licenses/Apache-2.0.txt | 202 ------ 10 files changed, 567 insertions(+), 975 deletions(-) delete mode 100644 THIRD-PARTY-NOTICES.md create mode 100644 app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/BatteryGaugeView.java delete mode 100644 app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/CircularProgressBar.java delete mode 100644 licenses/Apache-2.0.txt 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..e3cf296 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.BatteryGaugeView; 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 or critical breathing). + final BatteryGaugeView 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 BatteryGaugeView gauge = findViewById(R.id.batteryPercentage); + gauge.pauseAnimations(); } /** @@ -262,16 +262,16 @@ private void stopUpdateTimer() { * Runs on the main thread via {@link #handler}. */ private void refreshBatteryUi() { - final CircularProgressBar progressBar = findViewById(R.id.batteryPercentage); + final BatteryGaugeView 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 + // Update the wave direction based on battery status if (nonNull(batteryDO)) { final boolean isCharging = batteryDO.getStatus() == BatteryManager.BATTERY_STATUS_CHARGING; - progressBar.setCharging(isCharging); + gauge.setCharging(isCharging); } final FragmentManager fragmentManager = getSupportFragmentManager(); @@ -290,30 +290,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 BatteryGaugeView 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 +346,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 BatteryGaugeView gauge = findViewById(R.id.batteryPercentage); + gauge.setThresholds(critical, warning); } }); diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/BatteryGaugeView.java b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/BatteryGaugeView.java new file mode 100644 index 0000000..362d655 --- /dev/null +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/BatteryGaugeView.java @@ -0,0 +1,520 @@ +/* + * Simple Battery Notifier + * Copyright (C) 2016-2026 Al-Mothafar Al-Hasan + * + * 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 . + */ + +package com.almothafar.simplebatterynotifier.ui.widget; + +import android.animation.ValueAnimator; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.SweepGradient; +import android.graphics.Typeface; +import android.util.AttributeSet; +import android.view.View; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.view.animation.LinearInterpolator; +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import com.almothafar.simplebatterynotifier.R; + +import java.util.function.IntConsumer; + +import static java.util.Objects.nonNull; + +/** + * The home-screen battery gauge: a 270Β° ring, open at the bottom, filled clockwise to the current + * battery level, with the percentage and charge status drawn in the middle. + *

+ * The ring color follows the user's thresholds ({@link #setThresholds(int, int)}): normal above the + * warning level, warning color between the two, alert color at or below critical. Decorative motion + * depends on the battery state: + *

    + *
  • Charging β€” a soft highlight "wave" travels along the lit arc from its start to the + * level tip, suggesting energy flowing in.
  • + *
  • Discharging β€” the same wave, slower and reversed (tip back to start).
  • + *
  • Critical (not charging) β€” the whole gauge breathes (subtle scale + glow) instead.
  • + *
+ * All motion is paused/resumed by the host activity via {@link #pauseAnimations()} / + * {@link #resumeAnimations()} so nothing keeps invalidating while the screen is in the background. + *

+ * This widget was written from scratch for this project (issue #144); it replaces an earlier + * gauge that derived from a third-party library. + */ +public class BatteryGaugeView 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_CHARGING_MS = 2400; + private static final long WAVE_TRIP_DISCHARGING_MS = 8000; + private static final int WAVE_HIGHLIGHT_COLOR = 0x8CFFFFFF; + + /** Critical breathing: gentle shrink plus a glow that follows the breath. */ + private static final long BREATH_CYCLE_MS = 1500; + private static final float BREATH_MIN_SCALE = 0.95f; + private static final int BREATH_MAX_GLOW_ALPHA = 60; + private static final float GLOW_EXTRA_STROKE_DP = 8f; + + /** 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; + + // What the gauge shows. + private int level; + private int criticalLevel = 20; + private int warningLevel = 40; + private boolean charging; + private String title = ""; + private String statusText = ""; + + // 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 } + + private Motion motion = Motion.NONE; + + public BatteryGaugeView(final Context context) { + this(context, null); + } + + public BatteryGaugeView(final Context context, final AttributeSet attrs) { + this(context, attrs, 0); + } + + public BatteryGaugeView(final Context context, final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + // Arc shadows only render on a software layer; the gauge is small, so this is affordable. + setLayerType(View.LAYER_TYPE_SOFTWARE, null); + setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); + resolveAppearance(attrs, defStyleAttr); + announceLevel(); + } + + // ------------------------------------------------------------------ public API + + /** Show the given battery 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 the charge 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(); + } + + /** Flip the wave direction (and critical-breathing eligibility) for the charging state. */ + public void setCharging(final boolean charging) { + if (this.charging == charging) { + return; + } + this.charging = charging; + 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 activity 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 battery 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 = motion == Motion.BREATH && 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 (motion == Motion.BREATH && 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 battery 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_CHARGING_MS, false); + break; + case WAVE_REVERSE: + startWave(WAVE_TRIP_DISCHARGING_MS, true); + break; + case BREATH: + startBreath(); + break; + case NONE: + break; + } + } + + private Motion wantedMotion() { + if (!isAttachedToWindow() || level == 0) { + return Motion.NONE; + } + if (charging) { + return Motion.WAVE_FORWARD; + } + if (level <= criticalLevel) { + return Motion.BREATH; + } + return Motion.WAVE_REVERSE; + } + + 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 -> { + final float fraction = (float) animation.getAnimatedValue(); + waveTravel = reversed ? 1f - fraction : fraction; + invalidate(); + }); + motionAnimator.start(); + } + + private void startBreath() { + motionAnimator = ValueAnimator.ofFloat(BREATH_MIN_SCALE, 1f); + motionAnimator.setDuration(BREATH_CYCLE_MS); + 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 * BREATH_MAX_GLOW_ALPHA); + 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; + } + + @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; + + normalColor = ContextCompat.getColor(context, R.color.circular_progress_default_progress); + warningColor = ContextCompat.getColor(context, R.color.circular_progress_default_progress_warning); + alertColor = ContextCompat.getColor(context, R.color.circular_progress_default_progress_alert); + + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.BatteryGaugeView, defStyleAttr, 0); + try { + strokeWidthPx = a.getDimension(R.styleable.BatteryGaugeView_gaugeStrokeWidth, 25 * density); + titleBaseTextSizePx = a.getDimension(R.styleable.BatteryGaugeView_gaugeTitleTextSize, 64 * density); + statusBaseTextSizePx = a.getDimension(R.styleable.BatteryGaugeView_gaugeStatusTextSize, 20 * density); + showShadow = a.getBoolean(R.styleable.BatteryGaugeView_gaugeShowShadow, true); + + trackPaint.setColor(a.getColor(R.styleable.BatteryGaugeView_gaugeTrackColor, + ContextCompat.getColor(context, R.color.circular_progress_default_background))); + titlePaint.setColor(a.getColor(R.styleable.BatteryGaugeView_gaugeTitleColor, + ContextCompat.getColor(context, R.color.circular_progress_default_title))); + statusPaint.setColor(a.getColor(R.styleable.BatteryGaugeView_gaugeStatusColor, + ContextCompat.getColor(context, R.color.circular_progress_default_subtitle))); + + // Preview text so the gauge is meaningful in the layout editor. + title = orEmpty(a.getString(R.styleable.BatteryGaugeView_gaugeTitle)); + statusText = orEmpty(a.getString(R.styleable.BatteryGaugeView_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(getContext().getString(R.string.battery_progress_description, 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/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 . - */ - -package com.almothafar.simplebatterynotifier.ui.widget; - -import android.animation.Animator; -import android.animation.ValueAnimator; -import android.content.Context; -import android.content.res.Resources; -import android.content.res.TypedArray; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.Paint.Style; -import android.graphics.RectF; -import android.graphics.Typeface; -import android.util.AttributeSet; -import android.view.View; -import android.view.animation.LinearInterpolator; -import android.widget.ProgressBar; -import androidx.annotation.NonNull; -import com.almothafar.simplebatterynotifier.R; -import com.almothafar.simplebatterynotifier.util.GeneralHelper; - -import static java.util.Objects.isNull; -import static java.util.Objects.nonNull; - -/** - * Custom circular progress bar widget for displaying battery percentage - * with customizable colors, title, subtitle, and animation support - */ -public class CircularProgressBar extends ProgressBar { - private static final String TAG = "CircularProgressBar"; - - // Balanced modern design - private static final int STROKE_WIDTH = 25; // Balanced thickness - private static final long ANIMATION_DURATION = 1000; - private static final long PULSE_DURATION_CHARGING = 2000; // 2-second breathing cycle for charging - private static final long PULSE_DURATION_CRITICAL = 1500; // 1.5-second cycle for critical - private static final int DEFAULT_TITLE_SIZE = 64; // Slightly larger for readability - private static final int DEFAULT_SUBTITLE_SIZE = 20; - private static final int STROKE_PADDING = 4; - private static final float START_ANGLE = 135; - private static final float SWEEP_ANGLE = 270; - private static final float PULSE_MIN_SCALE = 0.95f; // Subtle pulse effect - private static final float PULSE_MAX_SCALE = 1.0f; - - // Warning and critical levels are static to maintain consistency across all instances - private static int warningLevel = 40; - private static int criticalLevel = 20; - - private final RectF circleBounds = new RectF(); - private final Paint progressColorPaint = new Paint(); - private final Paint backgroundColorPaint = new Paint(); - private final Paint titlePaint = new Paint(); - private final Paint subtitlePaint = new Paint(); - private final Paint glowPaint = new Paint(); - - private String title = ""; - private String subTitle = ""; - private boolean hasShadow = true; - private int shadowColor = Color.argb(180, 0, 0, 0); - - // Animation state - private boolean isCharging = false; - private boolean isCritical = false; - private ValueAnimator pulseAnimator; - private float currentPulseScale = 1.0f; - private int currentGlowAlpha = 0; - - // Progress state colors and glow stroke resolved once (in initialize) so onDraw() doesn't hit - // resources on every frame during the pulse animation. See issue #55. - private int progressDefaultColor; - private int progressWarningColor; - private int progressAlertColor; - private int glowExtraStrokePx; - - // Base (un-shrunk) title text size in px. onDraw() shrinks from this to fit wide values like "100%". - private float baseTitleTextSizePx; - - // Base (un-shrunk) subtitle text size in px. onDraw() shrinks from this to fit long status labels - // like "Not Charging" so they don't overrun the ring. See issue #91. - private float baseSubtitleTextSizePx; - - /** - * Constructor for programmatic instantiation - * - * @param context The context - */ - public CircularProgressBar(final Context context) { - super(context); - initialize(null, 0); - } - - /** - * Constructor for XML instantiation - * - * @param context The context - * @param attrs Attribute set from XML - */ - public CircularProgressBar(final Context context, final AttributeSet attrs) { - super(context, attrs); - initialize(attrs, 0); - } - - /** - * Constructor for XML instantiation with style - * - * @param context The context - * @param attrs Attribute set from XML - * @param defStyle Default style attribute - */ - public CircularProgressBar(final Context context, final AttributeSet attrs, final int defStyle) { - super(context, attrs, defStyle); - initialize(attrs, defStyle); - } - - /** - * Animate the progress from start value to end value - * - * @param start Starting progress value - * @param end Ending progress value - * @param listener Animation progress listener - */ - public void animateProgressTo(final int start, final int end, final ProgressAnimationListener listener) { - if (start != 0) { - setProgress(start); - } - - // Use ValueAnimator instead of ObjectAnimator (no property setter needed) - final ValueAnimator progressBarAnimator = ValueAnimator.ofInt(start, end); - progressBarAnimator.setDuration(ANIMATION_DURATION); - progressBarAnimator.setInterpolator(new LinearInterpolator()); - - // Use AnimatorListenerAdapter to avoid empty method implementations - progressBarAnimator.addListener(new android.animation.AnimatorListenerAdapter() { - @Override - public void onAnimationEnd(final Animator animation) { - CircularProgressBar.this.setProgress(end); - if (nonNull(listener)) { - listener.onAnimationFinish(); - } - } - - @Override - public void onAnimationStart(final Animator animation) { - if (nonNull(listener)) { - listener.onAnimationStart(); - } - } - }); - - progressBarAnimator.addUpdateListener(animation -> { - final int progress = (Integer) animation.getAnimatedValue(); - CircularProgressBar.this.setProgress(progress); - if (nonNull(listener)) { - listener.onAnimationProgress(progress); - } - }); - progressBarAnimator.start(); - } - - /** - * Set the subtitle text - * - * @param subtitle The subtitle text to display - */ - public synchronized void setSubTitle(final String subtitle) { - this.subTitle = subtitle; - invalidate(); - } - - /** - * Set the subtitle text color - * - * @param color The subtitle color - */ - public synchronized void setSubTitleColor(final int color) { - subtitlePaint.setColor(color); - invalidate(); - } - - /** - * Set the title text color - * - * @param color The title color - */ - public synchronized void setTitleColor(final int color) { - titlePaint.setColor(color); - invalidate(); - } - - /** - * Set the shadow color - * - * @param color The shadow color - */ - public synchronized void setShadow(final int color) { - this.shadowColor = color; - invalidate(); - } - - /** - * Get the title text - * - * @return The title text - */ - public String getTitle() { - return title; - } - - /** - * Set the title text - * - * @param title The title text to display - */ - public synchronized void setTitle(final String title) { - this.title = title; - invalidate(); - } - - /** - * Check if shadow is enabled - * - * @return True if shadow is enabled - */ - public boolean getHasShadow() { - return hasShadow; - } - - /** - * Enable or disable shadow - * - * @param flag True to enable shadow, false to disable - */ - public synchronized void setHasShadow(final boolean flag) { - this.hasShadow = flag; - invalidate(); - } - - /** - * Get the warning level percentage - * - * @return Warning level percentage - */ - public int getWarningLevel() { - return warningLevel; - } - - /** - * Set the warning level percentage - * - * @param warningLevel Warning level percentage (e.g., 40 for 40%) - */ - public void setWarningLevel(final int warningLevel) { - CircularProgressBar.warningLevel = warningLevel; - } - - /** - * Get the critical level percentage - * - * @return Critical level percentage - */ - public int getCriticalLevel() { - return criticalLevel; - } - - /** - * Set the critical level percentage - * - * @param criticalLevel Critical level percentage (e.g., 20 for 20%) - */ - public void setCriticalLevel(final int criticalLevel) { - CircularProgressBar.criticalLevel = criticalLevel; - } - - /** - * Set charging state and update pulse animation - * - * @param charging True if battery is charging, false otherwise - */ - public void setCharging(final boolean charging) { - if (this.isCharging == charging) { - return; // No change in state - } - - this.isCharging = charging; - updateAnimationState(); - } - - /** - * Update animation state based on charging and battery level - * Called internally when progress changes or charging state changes - */ - private void updateAnimationState() { - final int progress = getProgress(); - final boolean wasCritical = this.isCritical; - - // Update critical state (only when not charging) - this.isCritical = !isCharging && progress <= criticalLevel; - - // Determine if we need pulse animation - final boolean needsPulse = isCharging || isCritical; - - // If animation state changed, restart with appropriate settings - if (needsPulse && (!nonNull(pulseAnimator) || !pulseAnimator.isRunning() || wasCritical != isCritical)) { - stopPulseAnimation(); - startPulseAnimation(); - } else if (!needsPulse) { - stopPulseAnimation(); - } - } - - /** - * Start the gentle breathing pulse animation - * Uses different duration based on charging vs critical state - */ - private void startPulseAnimation() { - if (nonNull(pulseAnimator) && pulseAnimator.isRunning()) { - return; // Already running - } - - // Determine pulse duration and intensity based on state - final long pulseDuration = isCharging ? PULSE_DURATION_CHARGING : PULSE_DURATION_CRITICAL; - final int maxGlowAlpha = isCritical ? 60 : 40; // More intense glow for critical - - // Create pulse animator that oscillates between min and max scale - pulseAnimator = ValueAnimator.ofFloat(PULSE_MIN_SCALE, PULSE_MAX_SCALE); - pulseAnimator.setDuration(pulseDuration); - pulseAnimator.setRepeatCount(ValueAnimator.INFINITE); - pulseAnimator.setRepeatMode(ValueAnimator.REVERSE); - pulseAnimator.setInterpolator(new android.view.animation.AccelerateDecelerateInterpolator()); - - pulseAnimator.addUpdateListener(animation -> { - currentPulseScale = (float) animation.getAnimatedValue(); - // Glow alpha varies with scale - currentGlowAlpha = (int) ((currentPulseScale - PULSE_MIN_SCALE) / (PULSE_MAX_SCALE - PULSE_MIN_SCALE) * maxGlowAlpha); - invalidate(); // Trigger redraw - }); - - pulseAnimator.start(); - } - - /** - * Stop the pulse animation - */ - private void stopPulseAnimation() { - if (nonNull(pulseAnimator)) { - pulseAnimator.cancel(); - pulseAnimator = null; - } - currentPulseScale = 1.0f; - currentGlowAlpha = 0; - invalidate(); - } - - /** - * Pause the ongoing pulse animation when the host activity leaves the foreground. - *

- * 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/res/layout-land/activity_main.xml b/app/src/main/res/layout-land/activity_main.xml index 23d16e1..72a73e6 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 @@ - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index b1ee3fe..ab131a2 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -32,13 +32,13 @@ android:background="@color/top_background_color" android:id="@+id/percentageLayout"> - diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 3dba99a..b471286 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -1,16 +1,15 @@ - - - - - - - - - - - + + + + + + + + + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 4f54e0c..7e49eb8 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -18,21 +18,13 @@ - - diff --git a/licenses/Apache-2.0.txt b/licenses/Apache-2.0.txt deleted file mode 100644 index 57bc88a..0000000 --- a/licenses/Apache-2.0.txt +++ /dev/null @@ -1,202 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - From 25e2a6eb7db4f55dd493a8ffbd3aa2268ccba9f7 Mon Sep 17 00:00:00 2001 From: Al-Mothafar Al-Hasan Date: Mon, 13 Jul 2026 10:13:23 +0300 Subject: [PATCH 2/5] Make the wave opt-in; the home gauge goes back to breath-or-still (#144) On-device testing (Mate 10 Pro) showed the always-on wave kept the CPU busy: the gauge sits on a software layer (arc shadows need one) and the wave invalidates every frame, so idle temperature rose noticeably. It also ran the reverse wave while the battery was full on the charger, which reads wrong. The wave stays in BatteryGaugeView as a documented capability for screens that want it, but is now opt-in via setWaveEnabled() and off by default. Without it the gauge matches the old widget's profile exactly: - charging: gentle 2s breathing pulse (glow alpha 40) - critical, not charging: faster 1.5s breathing (glow alpha 60) - anything else (full, idle, discharging): completely still - zero redraws Verified on device: two gauge-region screenshots 2s apart hash identical while full on the charger. Co-Authored-By: Claude Opus 4.8 --- .../ui/MainActivity.java | 2 +- .../ui/widget/BatteryGaugeView.java | 68 +++++++++++++------ 2 files changed, 50 insertions(+), 20 deletions(-) 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 e3cf296..53af630 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/MainActivity.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/MainActivity.java @@ -268,7 +268,7 @@ private void refreshBatteryUi() { gauge.setTitle(batteryPercentage + "%"); gauge.setStatusText(subTitle); - // Update the wave direction based on battery status + // Update the charging motion (breathing pulse) based on battery status if (nonNull(batteryDO)) { final boolean isCharging = batteryDO.getStatus() == BatteryManager.BATTERY_STATUS_CHARGING; gauge.setCharging(isCharging); diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/BatteryGaugeView.java b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/BatteryGaugeView.java index 362d655..f2fac77 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/BatteryGaugeView.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/BatteryGaugeView.java @@ -48,11 +48,18 @@ * warning level, warning color between the two, alert color at or below critical. Decorative motion * depends on the battery state: *

    - *
  • Charging β€” a soft highlight "wave" travels along the lit arc from its start to the - * level tip, suggesting energy flowing in.
  • - *
  • Discharging β€” the same wave, slower and reversed (tip back to start).
  • - *
  • Critical (not charging) β€” the whole gauge breathes (subtle scale + glow) instead.
  • + *
  • Charging β€” the whole gauge breathes gently (subtle scale + glow).
  • + *
  • Critical (not charging) β€” the same breathing, faster and with a stronger glow.
  • + *
  • Otherwise β€” completely still, so an idle or full battery costs nothing to show.
  • *
+ *

+ * Optional wave mode ({@link #setWaveEnabled(boolean)}, off by default): replaces the + * charging breath with a soft highlight "wave" traveling along the lit arc β€” start to tip while + * charging (energy flowing in), slower tip to start while discharging. Waves redraw the view on + * every animation frame, and this view sits on a software layer (arc shadows need one), so the + * mode has a real, continuous CPU cost while visible. It exists as a documented capability for + * screens that want it; the home gauge deliberately leaves it off. + *

* All motion is paused/resumed by the host activity via {@link #pauseAnimations()} / * {@link #resumeAnimations()} so nothing keeps invalidating while the screen is in the background. *

@@ -75,10 +82,12 @@ public class BatteryGaugeView extends View { private static final long WAVE_TRIP_DISCHARGING_MS = 8000; private static final int WAVE_HIGHLIGHT_COLOR = 0x8CFFFFFF; - /** Critical breathing: gentle shrink plus a glow that follows the breath. */ - private static final long BREATH_CYCLE_MS = 1500; + /** Breathing: gentle shrink plus a glow that follows the breath; critical is faster and brighter. */ + private static final long BREATH_CYCLE_CHARGING_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_MAX_GLOW_ALPHA = 60; + private static final int BREATH_GLOW_ALPHA_CHARGING = 40; + private static final int BREATH_GLOW_ALPHA_CRITICAL = 60; private static final float GLOW_EXTRA_STROKE_DP = 8f; /** The track ring is drawn slightly wider than the level ring, framing it. */ @@ -93,6 +102,7 @@ public class BatteryGaugeView extends View { private int criticalLevel = 20; private int warningLevel = 40; private boolean charging; + private boolean waveEnabled; private String title = ""; private String statusText = ""; @@ -124,7 +134,7 @@ public class BatteryGaugeView extends View { private float breathScale = 1f; private int glowAlpha; - private enum Motion { NONE, WAVE_FORWARD, WAVE_REVERSE, BREATH } + private enum Motion { NONE, WAVE_FORWARD, WAVE_REVERSE, BREATH_CHARGING, BREATH_CRITICAL } private Motion motion = Motion.NONE; @@ -179,7 +189,7 @@ public void setThresholds(final int critical, final int warning) { invalidate(); } - /** Flip the wave direction (and critical-breathing eligibility) for the charging state. */ + /** Flip the charging state; motion (breath, or wave direction in wave mode) follows it. */ public void setCharging(final boolean charging) { if (this.charging == charging) { return; @@ -189,6 +199,19 @@ public void setCharging(final boolean charging) { invalidate(); } + /** + * Opt into the traveling-highlight wave (see the class doc for the CPU trade-off). Off by + * default: without it, charging breathes, critical breathes faster, everything else is 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. @@ -257,7 +280,7 @@ protected void onSizeChanged(final int w, final int h, final int oldW, final int protected void onDraw(@NonNull final Canvas canvas) { super.onDraw(canvas); - final boolean breathing = motion == Motion.BREATH && breathScale != 1f; + final boolean breathing = isBreathing() && breathScale != 1f; if (breathing) { canvas.save(); canvas.scale(breathScale, breathScale, getWidth() / 2f, getHeight() / 2f); @@ -274,7 +297,7 @@ protected void onDraw(@NonNull final Canvas canvas) { levelPaint.clearShadowLayer(); } - if (motion == Motion.BREATH && glowAlpha > 0) { + if (isBreathing() && glowAlpha > 0) { glowPaint.setColor(ringColor); glowPaint.setAlpha(glowAlpha); canvas.drawArc(ring, ARC_START, litSweep, false, glowPaint); @@ -364,8 +387,11 @@ private void refreshMotion() { case WAVE_REVERSE: startWave(WAVE_TRIP_DISCHARGING_MS, true); break; - case BREATH: - startBreath(); + case BREATH_CHARGING: + startBreath(BREATH_CYCLE_CHARGING_MS, BREATH_GLOW_ALPHA_CHARGING); + break; + case BREATH_CRITICAL: + startBreath(BREATH_CYCLE_CRITICAL_MS, BREATH_GLOW_ALPHA_CRITICAL); break; case NONE: break; @@ -377,12 +403,12 @@ private Motion wantedMotion() { return Motion.NONE; } if (charging) { - return Motion.WAVE_FORWARD; + return waveEnabled ? Motion.WAVE_FORWARD : Motion.BREATH_CHARGING; } if (level <= criticalLevel) { - return Motion.BREATH; + return Motion.BREATH_CRITICAL; } - return Motion.WAVE_REVERSE; + return waveEnabled ? Motion.WAVE_REVERSE : Motion.NONE; } private void startWave(final long tripMillis, final boolean reversed) { @@ -398,16 +424,16 @@ private void startWave(final long tripMillis, final boolean reversed) { motionAnimator.start(); } - private void startBreath() { + private void startBreath(final long cycleMillis, final int maxGlowAlpha) { motionAnimator = ValueAnimator.ofFloat(BREATH_MIN_SCALE, 1f); - motionAnimator.setDuration(BREATH_CYCLE_MS); + 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 * BREATH_MAX_GLOW_ALPHA); + glowAlpha = (int) (depth * maxGlowAlpha); invalidate(); }); motionAnimator.start(); @@ -428,6 +454,10 @@ private boolean isWaving() { return motion == Motion.WAVE_FORWARD || motion == Motion.WAVE_REVERSE; } + private boolean isBreathing() { + return motion == Motion.BREATH_CHARGING || motion == Motion.BREATH_CRITICAL; + } + @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); From 254a59ac86f601ac6122937e13ebdb179afd5b98 Mon Sep 17 00:00:00 2001 From: Al-Mothafar Al-Hasan Date: Mon, 13 Jul 2026 10:47:28 +0300 Subject: [PATCH 3/5] Three-state gauge motion + make the widget reusable and cheaper to render (#144) Motion now follows a three-state power model (setPowerState replaces the boolean setCharging; MainActivity maps the OS battery status onto it): - CHARGING: highlight wave travels start -> tip (~2.4s trips) - ON_BATTERY: slower wave tip -> start (~8s trips); at/below critical the urgent breathing pulse takes precedence - FULL (on charger): one gentle pulse every 3s; between pulses the update listener bails out before invalidating, so the view truly draws nothing Waves are on by default now; setWaveEnabled(false) remains for battery-sensitive hosts (charging falls back to the breathing pulse and discharging goes still). Rendering cost: hardware-accelerated on Android 9+ (Skia hardware canvases draw arc shadows fine; only Android 8 keeps the software-layer fallback), and wave frames that would move the band less than a visible step are skipped. This addresses the idle-temperature rise seen on the Mate 10 Pro with the always-software-layer version. Reusability: the class no longer references app colors or strings - ring/track/ text colors and the screen-reader description template are now gauge* attributes with built-in defaults (the app style supplies its palette and localized string), so the file plus its declare-styleable block can be dropped into any project. GPLv3 header already travels in the file. Verified on device (full on charger): a 10-frame burst shows identical resting frames with distinct frames only during the periodic pulse. Co-Authored-By: Claude Opus 4.8 --- .../ui/MainActivity.java | 20 ++- .../ui/widget/BatteryGaugeView.java | 147 +++++++++++++----- app/src/main/res/values/attrs.xml | 5 + app/src/main/res/values/styles.xml | 5 + 4 files changed, 132 insertions(+), 45 deletions(-) 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 53af630..5a699eb 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/MainActivity.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/MainActivity.java @@ -268,10 +268,9 @@ private void refreshBatteryUi() { gauge.setTitle(batteryPercentage + "%"); gauge.setStatusText(subTitle); - // Update the charging motion (breathing pulse) 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; - gauge.setCharging(isCharging); + gauge.setPowerState(powerStateOf(batteryDO.getStatus())); } final FragmentManager fragmentManager = getSupportFragmentManager(); @@ -283,6 +282,21 @@ private void refreshBatteryUi() { } } + /** + * Map the OS battery status onto the gauge's three power states: charging animates forward, + * full-on-charger idles with a periodic pulse, anything else counts as running on battery. + */ + private static BatteryGaugeView.Power powerStateOf(final int batteryStatus) { + switch (batteryStatus) { + case BatteryManager.BATTERY_STATUS_CHARGING: + return BatteryGaugeView.Power.CHARGING; + case BatteryManager.BATTERY_STATUS_FULL: + return BatteryGaugeView.Power.FULL; + default: + return BatteryGaugeView.Power.ON_BATTERY; + } + } + /** * Initialize first values and animate the progress bar */ diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/BatteryGaugeView.java b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/BatteryGaugeView.java index f2fac77..fb0d36d 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/BatteryGaugeView.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/BatteryGaugeView.java @@ -28,14 +28,15 @@ import android.graphics.RectF; import android.graphics.SweepGradient; import android.graphics.Typeface; +import android.os.Build; import android.util.AttributeSet; import android.view.View; import android.view.animation.AccelerateDecelerateInterpolator; import android.view.animation.LinearInterpolator; import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; import com.almothafar.simplebatterynotifier.R; +import java.util.Locale; import java.util.function.IntConsumer; import static java.util.Objects.nonNull; @@ -46,25 +47,27 @@ *

* The ring color follows the user's thresholds ({@link #setThresholds(int, int)}): normal above the * warning level, warning color between the two, alert color at or below critical. Decorative motion - * depends on the battery state: + * follows the power state ({@link #setPowerState(Power)}): *

    - *
  • Charging β€” the whole gauge breathes gently (subtle scale + glow).
  • - *
  • Critical (not charging) β€” the same breathing, faster and with a stronger glow.
  • - *
  • Otherwise β€” completely still, so an idle or full battery costs nothing to show.
  • + *
  • {@link Power#CHARGING} β€” a soft highlight "wave" travels along the lit arc from its start + * to the level tip (energy flowing in), looping every ~2.4s.
  • + *
  • {@link Power#ON_BATTERY} β€” the same wave, slower (~8s) and reversed (tip back to start); + * except at or below the critical level, where the gauge breathes urgently instead.
  • + *
  • {@link Power#FULL} β€” a single gentle pulse every ~3 seconds; between pulses the view is + * completely idle (no redraws).
  • *
+ * Waves can be turned off wholesale with {@link #setWaveEnabled(boolean)} for battery-sensitive + * hosts; charging then falls back to a gentle breathing pulse and discharging goes still. *

- * Optional wave mode ({@link #setWaveEnabled(boolean)}, off by default): replaces the - * charging breath with a soft highlight "wave" traveling along the lit arc β€” start to tip while - * charging (energy flowing in), slower tip to start while discharging. Waves redraw the view on - * every animation frame, and this view sits on a software layer (arc shadows need one), so the - * mode has a real, continuous CPU cost while visible. It exists as a documented capability for - * screens that want it; the home gauge deliberately leaves it off. + * 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. *

- * All motion is paused/resumed by the host activity via {@link #pauseAnimations()} / - * {@link #resumeAnimations()} so nothing keeps invalidating while the screen is in the background. - *

- * This widget was written from scratch for this project (issue #144); it replaces an earlier - * gauge that derived from a third-party library. + * Reuse: the class is self-contained β€” to use it in another project, copy this file plus the + * {@code BatteryGaugeView} 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 BatteryGaugeView extends View { @@ -90,6 +93,19 @@ public class BatteryGaugeView extends View { private static final int BREATH_GLOW_ALPHA_CRITICAL = 60; private static final float GLOW_EXTRA_STROKE_DP = 8f; + /** Full-on-charger: 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 = "Battery 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; @@ -97,14 +113,18 @@ public class BatteryGaugeView extends View { private static final float TITLE_MAX_WIDTH_FRACTION = 0.68f; private static final float STATUS_MAX_CHORD_FRACTION = 0.9f; + /** The battery's power situation, as far as the gauge cares about it. */ + public enum Power { CHARGING, FULL, ON_BATTERY } + // What the gauge shows. private int level; private int criticalLevel = 20; private int warningLevel = 40; - private boolean charging; - private boolean waveEnabled; + private Power power = Power.ON_BATTERY; + 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; @@ -134,7 +154,7 @@ public class BatteryGaugeView extends View { private float breathScale = 1f; private int glowAlpha; - private enum Motion { NONE, WAVE_FORWARD, WAVE_REVERSE, BREATH_CHARGING, BREATH_CRITICAL } + private enum Motion { NONE, WAVE_FORWARD, WAVE_REVERSE, BREATH_CHARGING, BREATH_CRITICAL, PULSE_IDLE } private Motion motion = Motion.NONE; @@ -148,8 +168,11 @@ public BatteryGaugeView(final Context context, final AttributeSet attrs) { public BatteryGaugeView(final Context context, final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); - // Arc shadows only render on a software layer; the gauge is small, so this is affordable. - setLayerType(View.LAYER_TYPE_SOFTWARE, null); + // 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(); @@ -189,19 +212,19 @@ public void setThresholds(final int critical, final int warning) { invalidate(); } - /** Flip the charging state; motion (breath, or wave direction in wave mode) follows it. */ - public void setCharging(final boolean charging) { - if (this.charging == charging) { + /** Tell the gauge the battery's power situation; the motion (wave/pulse/breath) follows it. */ + public void setPowerState(final Power power) { + if (this.power == power) { return; } - this.charging = charging; + this.power = power; refreshMotion(); invalidate(); } /** - * Opt into the traveling-highlight wave (see the class doc for the CPU trade-off). Off by - * default: without it, charging breathes, critical breathes faster, everything else is still. + * Waves are on by default; battery-sensitive hosts can turn them off, in which case charging + * falls back to a gentle breathing pulse and discharging goes completely still. */ public void setWaveEnabled(final boolean waveEnabled) { if (this.waveEnabled == waveEnabled) { @@ -393,6 +416,9 @@ private void refreshMotion() { case BREATH_CRITICAL: startBreath(BREATH_CYCLE_CRITICAL_MS, BREATH_GLOW_ALPHA_CRITICAL); break; + case PULSE_IDLE: + startIdlePulse(); + break; case NONE: break; } @@ -402,9 +428,12 @@ private Motion wantedMotion() { if (!isAttachedToWindow() || level == 0) { return Motion.NONE; } - if (charging) { + if (power == Power.CHARGING) { return waveEnabled ? Motion.WAVE_FORWARD : Motion.BREATH_CHARGING; } + if (power == Power.FULL) { + return Motion.PULSE_IDLE; + } if (level <= criticalLevel) { return Motion.BREATH_CRITICAL; } @@ -417,8 +446,42 @@ private void startWave(final long tripMillis, final boolean reversed) { motionAnimator.setInterpolator(new LinearInterpolator()); motionAnimator.setRepeatCount(ValueAnimator.INFINITE); motionAnimator.addUpdateListener(animation -> { - final float fraction = (float) animation.getAnimatedValue(); - waveTravel = reversed ? 1f - fraction : fraction; + // 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 battery sitting on + * the charger 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(); @@ -455,7 +518,7 @@ private boolean isWaving() { } private boolean isBreathing() { - return motion == Motion.BREATH_CHARGING || motion == Motion.BREATH_CRITICAL; + return motion == Motion.BREATH_CHARGING || motion == Motion.BREATH_CRITICAL || motion == Motion.PULSE_IDLE; } @Override @@ -480,10 +543,6 @@ private void resolveAppearance(final AttributeSet attrs, final int defStyleAttr) final Context context = getContext(); final float density = getResources().getDisplayMetrics().density; - normalColor = ContextCompat.getColor(context, R.color.circular_progress_default_progress); - warningColor = ContextCompat.getColor(context, R.color.circular_progress_default_progress_warning); - alertColor = ContextCompat.getColor(context, R.color.circular_progress_default_progress_alert); - final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.BatteryGaugeView, defStyleAttr, 0); try { strokeWidthPx = a.getDimension(R.styleable.BatteryGaugeView_gaugeStrokeWidth, 25 * density); @@ -491,12 +550,16 @@ private void resolveAppearance(final AttributeSet attrs, final int defStyleAttr) statusBaseTextSizePx = a.getDimension(R.styleable.BatteryGaugeView_gaugeStatusTextSize, 20 * density); showShadow = a.getBoolean(R.styleable.BatteryGaugeView_gaugeShowShadow, true); - trackPaint.setColor(a.getColor(R.styleable.BatteryGaugeView_gaugeTrackColor, - ContextCompat.getColor(context, R.color.circular_progress_default_background))); - titlePaint.setColor(a.getColor(R.styleable.BatteryGaugeView_gaugeTitleColor, - ContextCompat.getColor(context, R.color.circular_progress_default_title))); - statusPaint.setColor(a.getColor(R.styleable.BatteryGaugeView_gaugeStatusColor, - ContextCompat.getColor(context, R.color.circular_progress_default_subtitle))); + normalColor = a.getColor(R.styleable.BatteryGaugeView_gaugeLevelColor, DEFAULT_LEVEL_COLOR); + warningColor = a.getColor(R.styleable.BatteryGaugeView_gaugeWarningColor, DEFAULT_WARNING_COLOR); + alertColor = a.getColor(R.styleable.BatteryGaugeView_gaugeAlertColor, DEFAULT_ALERT_COLOR); + trackPaint.setColor(a.getColor(R.styleable.BatteryGaugeView_gaugeTrackColor, DEFAULT_TRACK_COLOR)); + titlePaint.setColor(a.getColor(R.styleable.BatteryGaugeView_gaugeTitleColor, DEFAULT_TEXT_COLOR)); + statusPaint.setColor(a.getColor(R.styleable.BatteryGaugeView_gaugeStatusColor, DEFAULT_TEXT_COLOR)); + + // Localizable "Battery at %1$d percent" template for screen readers. + final String description = a.getString(R.styleable.BatteryGaugeView_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.BatteryGaugeView_gaugeTitle)); @@ -537,7 +600,7 @@ private int colorForLevel() { } private void announceLevel() { - setContentDescription(getContext().getString(R.string.battery_progress_description, level)); + setContentDescription(String.format(Locale.getDefault(), levelDescriptionFormat, level)); } private static int clampLevel(final int value) { diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index b471286..6dd2a55 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -3,6 +3,9 @@ + + + @@ -10,6 +13,8 @@ + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 7e49eb8..d95e407 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -21,10 +21,15 @@ From d4093a9d6cf48e862f3963323572a3fc455f480d Mon Sep 17 00:00:00 2001 From: Al-Mothafar Al-Hasan Date: Mon, 13 Jul 2026 11:01:22 +0300 Subject: [PATCH 4/5] Rename BatteryGaugeView to HorseshoeProgressBar; de-battery-fy the API (#144) The widget is a generic fill/drain progress gauge - nothing in it is battery specific - so give it a name that says what it looks like rather than what this app uses it for. Along with the class rename: - Power { CHARGING, FULL, ON_BATTERY } becomes Flow { FILLING, FULL, DRAINING } and setPowerState() becomes setFlow(); MainActivity maps battery status onto the generic flow states. - Battery-flavored constant names and doc wording genericized; the default screen-reader template becomes "Level at %1$d percent" (the app style still supplies the localized battery string). - The styleable follows the class name (HorseshoeProgressBar); the app-side style stays Widget.App.BatteryGauge since it is the battery-flavored styling of the generic widget. Co-Authored-By: Claude Opus 4.8 --- .../ui/MainActivity.java | 28 ++-- ...ugeView.java => HorseshoeProgressBar.java} | 137 +++++++++--------- .../main/res/layout-land/activity_main.xml | 2 +- app/src/main/res/layout/activity_main.xml | 2 +- app/src/main/res/values/attrs.xml | 2 +- 5 files changed, 87 insertions(+), 84 deletions(-) rename app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/{BatteryGaugeView.java => HorseshoeProgressBar.java} (79%) 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 5a699eb..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.BatteryGaugeView; +import com.almothafar.simplebatterynotifier.ui.widget.HorseshoeProgressBar; import java.util.List; @@ -192,8 +192,8 @@ protected void onPostResume() { startUpdateTimer(); // Resume the motion paused in onPause(); restarts only what the battery state still - // warrants (charging/discharging wave or critical breathing). - final BatteryGaugeView gauge = findViewById(R.id.batteryPercentage); + // warrants (charging/discharging wave, full pulse, or critical breathing). + final HorseshoeProgressBar gauge = findViewById(R.id.batteryPercentage); gauge.resumeAnimations(); } @@ -208,7 +208,7 @@ protected void onPause() { // 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 BatteryGaugeView gauge = findViewById(R.id.batteryPercentage); + final HorseshoeProgressBar gauge = findViewById(R.id.batteryPercentage); gauge.pauseAnimations(); } @@ -262,7 +262,7 @@ private void stopUpdateTimer() { * Runs on the main thread via {@link #handler}. */ private void refreshBatteryUi() { - final BatteryGaugeView gauge = findViewById(R.id.batteryPercentage); + final HorseshoeProgressBar gauge = findViewById(R.id.batteryPercentage); fillBatteryInfo(); gauge.setLevel(batteryPercentage); gauge.setTitle(batteryPercentage + "%"); @@ -270,7 +270,7 @@ private void refreshBatteryUi() { // Drive the gauge motion: charging wave, full-on-charger idle pulse, or discharge wave. if (nonNull(batteryDO)) { - gauge.setPowerState(powerStateOf(batteryDO.getStatus())); + gauge.setFlow(flowOf(batteryDO.getStatus())); } final FragmentManager fragmentManager = getSupportFragmentManager(); @@ -283,17 +283,17 @@ private void refreshBatteryUi() { } /** - * Map the OS battery status onto the gauge's three power states: charging animates forward, - * full-on-charger idles with a periodic pulse, anything else counts as running on battery. + * 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 BatteryGaugeView.Power powerStateOf(final int batteryStatus) { + private static HorseshoeProgressBar.Flow flowOf(final int batteryStatus) { switch (batteryStatus) { case BatteryManager.BATTERY_STATUS_CHARGING: - return BatteryGaugeView.Power.CHARGING; + return HorseshoeProgressBar.Flow.FILLING; case BatteryManager.BATTERY_STATUS_FULL: - return BatteryGaugeView.Power.FULL; + return HorseshoeProgressBar.Flow.FULL; default: - return BatteryGaugeView.Power.ON_BATTERY; + return HorseshoeProgressBar.Flow.DRAINING; } } @@ -304,7 +304,7 @@ private void initializeFirstValues() { fillBatteryInfo(); final SharedPreferences sharedPref = PreferenceManager.getDefaultSharedPreferences(this); - final BatteryGaugeView gauge = findViewById(R.id.batteryPercentage); + 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)); @@ -360,7 +360,7 @@ public void onStopTrackingTouch(@NonNull final RangeSlider slider) { .putInt(getString(R.string._pref_key_warn_battery_level), warning) .apply(); - final BatteryGaugeView gauge = findViewById(R.id.batteryPercentage); + final HorseshoeProgressBar gauge = findViewById(R.id.batteryPercentage); gauge.setThresholds(critical, warning); } }); diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/BatteryGaugeView.java b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/HorseshoeProgressBar.java similarity index 79% rename from app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/BatteryGaugeView.java rename to app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/HorseshoeProgressBar.java index fb0d36d..370e3b3 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/BatteryGaugeView.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/HorseshoeProgressBar.java @@ -42,22 +42,24 @@ import static java.util.Objects.nonNull; /** - * The home-screen battery gauge: a 270Β° ring, open at the bottom, filled clockwise to the current - * battery level, with the percentage and charge status drawn in the middle. + * A horseshoe-shaped progress gauge: a 270Β° ring, open at the bottom, filled clockwise to the + * current level (0–100), with a large title and a smaller status line drawn in the middle. + * Battery is its home use case here, but nothing in it is battery-specific β€” it fits any value + * that fills and drains (downloads, quotas, tank levels, …). *

- * The ring color follows the user's 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 power state ({@link #setPowerState(Power)}): + * 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)}): *

    - *
  • {@link Power#CHARGING} β€” a soft highlight "wave" travels along the lit arc from its start - * to the level tip (energy flowing in), looping every ~2.4s.
  • - *
  • {@link Power#ON_BATTERY} β€” the same wave, slower (~8s) and reversed (tip back to start); + *
  • {@link Flow#FILLING} β€” a soft highlight "wave" travels along the lit arc from its start + * to the level tip (something flowing in), looping every ~2.4s.
  • + *
  • {@link Flow#DRAINING} β€” the same wave, slower (~8s) and reversed (tip back to start); * except at or below the critical level, where the gauge breathes urgently instead.
  • - *
  • {@link Power#FULL} β€” a single gentle pulse every ~3 seconds; between pulses the view is + *
  • {@link Flow#FULL} β€” a single gentle pulse every ~3 seconds; between pulses the view is * completely idle (no redraws).
  • *
* Waves can be turned off wholesale with {@link #setWaveEnabled(boolean)} for battery-sensitive - * hosts; charging then falls back to a gentle breathing pulse and discharging goes still. + * hosts; filling then falls back to a gentle breathing pulse and draining goes still. *

* 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 @@ -65,11 +67,12 @@ * {@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 BatteryGaugeView} 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. + * {@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 BatteryGaugeView extends View { +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; @@ -81,19 +84,19 @@ public class BatteryGaugeView extends View { /** 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_CHARGING_MS = 2400; - private static final long WAVE_TRIP_DISCHARGING_MS = 8000; + 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_CHARGING_MS = 2000; + 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_CHARGING = 40; + 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-on-charger: one gentle pulse per period; the rest of the period draws nothing. */ + /** 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; @@ -104,7 +107,7 @@ public class BatteryGaugeView extends View { 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 = "Battery at %1$d percent"; + 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; @@ -113,14 +116,14 @@ public class BatteryGaugeView extends View { private static final float TITLE_MAX_WIDTH_FRACTION = 0.68f; private static final float STATUS_MAX_CHORD_FRACTION = 0.9f; - /** The battery's power situation, as far as the gauge cares about it. */ - public enum Power { CHARGING, FULL, ON_BATTERY } + /** 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 Power power = Power.ON_BATTERY; + private Flow flow = Flow.DRAINING; private boolean waveEnabled = true; private String title = ""; private String statusText = ""; @@ -154,19 +157,19 @@ public enum Power { CHARGING, FULL, ON_BATTERY } private float breathScale = 1f; private int glowAlpha; - private enum Motion { NONE, WAVE_FORWARD, WAVE_REVERSE, BREATH_CHARGING, BREATH_CRITICAL, PULSE_IDLE } + private enum Motion { NONE, WAVE_FORWARD, WAVE_REVERSE, BREATH_FILLING, BREATH_CRITICAL, PULSE_IDLE } private Motion motion = Motion.NONE; - public BatteryGaugeView(final Context context) { + public HorseshoeProgressBar(final Context context) { this(context, null); } - public BatteryGaugeView(final Context context, final AttributeSet attrs) { + public HorseshoeProgressBar(final Context context, final AttributeSet attrs) { this(context, attrs, 0); } - public BatteryGaugeView(final Context context, final AttributeSet attrs, final int defStyleAttr) { + 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. @@ -180,7 +183,7 @@ public BatteryGaugeView(final Context context, final AttributeSet attrs, final i // ------------------------------------------------------------------ public API - /** Show the given battery level (0–100) immediately. */ + /** Show the given level (0–100) immediately. */ public void setLevel(final int level) { this.level = clampLevel(level); announceLevel(); @@ -198,7 +201,7 @@ public void setTitle(final String title) { invalidate(); } - /** The smaller line under the title, normally the charge status label. */ + /** The smaller line under the title, normally a status label. */ public void setStatusText(final String statusText) { this.statusText = nonNull(statusText) ? statusText : ""; invalidate(); @@ -212,19 +215,19 @@ public void setThresholds(final int critical, final int warning) { invalidate(); } - /** Tell the gauge the battery's power situation; the motion (wave/pulse/breath) follows it. */ - public void setPowerState(final Power power) { - if (this.power == power) { + /** 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.power = power; + this.flow = flow; refreshMotion(); invalidate(); } /** - * Waves are on by default; battery-sensitive hosts can turn them off, in which case charging - * falls back to a gentle breathing pulse and discharging goes completely still. + * 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) { @@ -256,14 +259,14 @@ public void animateLevelTo(final int target, final IntConsumer perStep) { } /** - * Stop all decorative motion while the host activity is backgrounded, so the gauge does not - * keep redrawing (and burning battery) when nobody can see it. + * 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 battery state calls for; counterpart of pause. */ + /** Restart whatever motion the current state calls for; counterpart of pause. */ public void resumeAnimations() { refreshMotion(); } @@ -393,8 +396,8 @@ private void drawCenterText(final Canvas canvas) { // ------------------------------------------------------------------ motion control /** - * Pick the motion the current battery 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. + * 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(); @@ -405,13 +408,13 @@ private void refreshMotion() { motion = wanted; switch (wanted) { case WAVE_FORWARD: - startWave(WAVE_TRIP_CHARGING_MS, false); + startWave(WAVE_TRIP_FILLING_MS, false); break; case WAVE_REVERSE: - startWave(WAVE_TRIP_DISCHARGING_MS, true); + startWave(WAVE_TRIP_DRAINING_MS, true); break; - case BREATH_CHARGING: - startBreath(BREATH_CYCLE_CHARGING_MS, BREATH_GLOW_ALPHA_CHARGING); + 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); @@ -428,10 +431,10 @@ private Motion wantedMotion() { if (!isAttachedToWindow() || level == 0) { return Motion.NONE; } - if (power == Power.CHARGING) { - return waveEnabled ? Motion.WAVE_FORWARD : Motion.BREATH_CHARGING; + if (flow == Flow.FILLING) { + return waveEnabled ? Motion.WAVE_FORWARD : Motion.BREATH_FILLING; } - if (power == Power.FULL) { + if (flow == Flow.FULL) { return Motion.PULSE_IDLE; } if (level <= criticalLevel) { @@ -462,8 +465,8 @@ private void startWave(final long tripMillis, final boolean reversed) { /** * 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 battery sitting on - * the charger costs almost nothing to display. + * 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); @@ -518,7 +521,7 @@ private boolean isWaving() { } private boolean isBreathing() { - return motion == Motion.BREATH_CHARGING || motion == Motion.BREATH_CRITICAL || motion == Motion.PULSE_IDLE; + return motion == Motion.BREATH_FILLING || motion == Motion.BREATH_CRITICAL || motion == Motion.PULSE_IDLE; } @Override @@ -543,27 +546,27 @@ 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.BatteryGaugeView, defStyleAttr, 0); + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.HorseshoeProgressBar, defStyleAttr, 0); try { - strokeWidthPx = a.getDimension(R.styleable.BatteryGaugeView_gaugeStrokeWidth, 25 * density); - titleBaseTextSizePx = a.getDimension(R.styleable.BatteryGaugeView_gaugeTitleTextSize, 64 * density); - statusBaseTextSizePx = a.getDimension(R.styleable.BatteryGaugeView_gaugeStatusTextSize, 20 * density); - showShadow = a.getBoolean(R.styleable.BatteryGaugeView_gaugeShowShadow, true); - - normalColor = a.getColor(R.styleable.BatteryGaugeView_gaugeLevelColor, DEFAULT_LEVEL_COLOR); - warningColor = a.getColor(R.styleable.BatteryGaugeView_gaugeWarningColor, DEFAULT_WARNING_COLOR); - alertColor = a.getColor(R.styleable.BatteryGaugeView_gaugeAlertColor, DEFAULT_ALERT_COLOR); - trackPaint.setColor(a.getColor(R.styleable.BatteryGaugeView_gaugeTrackColor, DEFAULT_TRACK_COLOR)); - titlePaint.setColor(a.getColor(R.styleable.BatteryGaugeView_gaugeTitleColor, DEFAULT_TEXT_COLOR)); - statusPaint.setColor(a.getColor(R.styleable.BatteryGaugeView_gaugeStatusColor, DEFAULT_TEXT_COLOR)); - - // Localizable "Battery at %1$d percent" template for screen readers. - final String description = a.getString(R.styleable.BatteryGaugeView_gaugeLevelDescription); + 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.BatteryGaugeView_gaugeTitle)); - statusText = orEmpty(a.getString(R.styleable.BatteryGaugeView_gaugeStatusText)); + title = orEmpty(a.getString(R.styleable.HorseshoeProgressBar_gaugeTitle)); + statusText = orEmpty(a.getString(R.styleable.HorseshoeProgressBar_gaugeStatusText)); } finally { a.recycle(); } diff --git a/app/src/main/res/layout-land/activity_main.xml b/app/src/main/res/layout-land/activity_main.xml index 72a73e6..19ff9af 100644 --- a/app/src/main/res/layout-land/activity_main.xml +++ b/app/src/main/res/layout-land/activity_main.xml @@ -16,7 +16,7 @@ android:background="@color/top_background_color" android:id="@+id/percentageLayout"> - - - + From 7f0a2245705aef5d3ca12f281d3565baddd21ea7 Mon Sep 17 00:00:00 2001 From: Al-Mothafar Al-Hasan Date: Mon, 13 Jul 2026 11:11:26 +0300 Subject: [PATCH 5/5] Make the widget's license header self-identifying for standalone copies (#144) HorseshoeProgressBar is meant to be copied into other projects, but its header only named the app, so a copied file would lose its provenance. Name the widget itself, link the source repo and author, and ask copiers to keep the header intact since it is the file's GPLv3 license and attribution notice. Co-Authored-By: Claude Opus 4.8 --- .../ui/widget/HorseshoeProgressBar.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 index 370e3b3..b777f2f 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/HorseshoeProgressBar.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/HorseshoeProgressBar.java @@ -1,6 +1,8 @@ /* - * Simple Battery Notifier - * Copyright (C) 2016-2026 Al-Mothafar Al-Hasan + * HorseshoeProgressBar - an animated horseshoe-shaped progress gauge for Android. + * Part of Simple Battery Notifier . + * + * Copyright (C) 2016-2026 Al-Mothafar Al-Hasan * * 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 @@ -14,6 +16,9 @@ * * You should have received a copy of the GNU General Public License * along with this program. If not, see . + * + * If you copy this class into another project, keep this header intact - it is the + * license and attribution notice for the file. GPLv3 applies to derivative works. */ package com.almothafar.simplebatterynotifier.ui.widget;