diff --git a/README.md b/README.md index 6ad200c..38d3b38 100644 --- a/README.md +++ b/README.md @@ -132,8 +132,6 @@ This is free, open-source software: you may use, study, modify, and share it, ** Copyright © 2024–2026 Al-Mothafar Al-Hasan -**Acknowledgements:** the battery gauge (`CircularProgressBar`) is derived from [circular_progress_bar](https://github.com/ylyc/circular_progress_bar) by Leon Cheng, used under the Apache License 2.0 — see [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md). - --- ## 🏆 Code Quality diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md deleted file mode 100644 index 1d61f19..0000000 --- a/THIRD-PARTY-NOTICES.md +++ /dev/null @@ -1,23 +0,0 @@ -# Third-Party Notices - -Simple Battery Notifier is licensed under the GNU GPL v3 (see [LICENSE.md](LICENSE.md)). -It includes third-party code that is provided under its own license, listed below. - -## circular_progress_bar - -- **Source:** https://github.com/ylyc/circular_progress_bar -- **Copyright:** Copyright (C) 2013 Leon Cheng -- **License:** Apache License, Version 2.0 — a full copy is provided in - [`licenses/Apache-2.0.txt`](licenses/Apache-2.0.txt), and is also available at - http://www.apache.org/licenses/LICENSE-2.0 - -The file -`app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/CircularProgressBar.java` -is **derived from** this project and has been **modified** (it extends `ProgressBar` -with a 270° gauge, adds the `cpb_*` styleable, auto-fitting title/subtitle, and a -pulse/glow animation, among other changes). - -The Apache License 2.0 is compatible with the GNU GPL v3. As permitted by that -license, the derived file and the combined work are distributed under the GNU GPL v3, -while the original portions remain subject to the Apache 2.0 copyright and notice -above. diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/MainActivity.java b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/MainActivity.java index 6933e26..de553d2 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/MainActivity.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/MainActivity.java @@ -35,7 +35,7 @@ import com.almothafar.simplebatterynotifier.service.BatteryHealthTracker; import com.almothafar.simplebatterynotifier.service.PowerConnectionService; import com.almothafar.simplebatterynotifier.service.SystemService; -import com.almothafar.simplebatterynotifier.ui.widget.CircularProgressBar; +import com.almothafar.simplebatterynotifier.ui.widget.HorseshoeProgressBar; import java.util.List; @@ -191,10 +191,10 @@ protected void onPostResume() { startUpdateTimer(); - // Resume the pulse paused in onPause(); restarts only if the battery state still - // warrants it (charging or critical). - final CircularProgressBar progressBar = findViewById(R.id.batteryPercentage); - progressBar.resumePulseAnimation(); + // Resume the motion paused in onPause(); restarts only what the battery state still + // warrants (charging/discharging wave, full pulse, or critical breathing). + final HorseshoeProgressBar gauge = findViewById(R.id.batteryPercentage); + gauge.resumeAnimations(); } /** @@ -206,10 +206,10 @@ protected void onPause() { super.onPause(); stopUpdateTimer(); - // The pulse is only auto-stopped when the view is destroyed (onDetachedFromWindow), + // Motion is only auto-stopped when the view is destroyed (onDetachedFromWindow), // not on backgrounding, so pause it here for the same reason we stop the timer. - final CircularProgressBar progressBar = findViewById(R.id.batteryPercentage); - progressBar.pausePulseAnimation(); + final HorseshoeProgressBar gauge = findViewById(R.id.batteryPercentage); + gauge.pauseAnimations(); } /** @@ -262,16 +262,15 @@ private void stopUpdateTimer() { * Runs on the main thread via {@link #handler}. */ private void refreshBatteryUi() { - final CircularProgressBar progressBar = findViewById(R.id.batteryPercentage); + final HorseshoeProgressBar gauge = findViewById(R.id.batteryPercentage); fillBatteryInfo(); - progressBar.setProgress(batteryPercentage); - progressBar.setTitle(batteryPercentage + "%"); - progressBar.setSubTitle(subTitle); + gauge.setLevel(batteryPercentage); + gauge.setTitle(batteryPercentage + "%"); + gauge.setStatusText(subTitle); - // Update charging animation based on battery status + // Drive the gauge motion: charging wave, full-on-charger idle pulse, or discharge wave. if (nonNull(batteryDO)) { - final boolean isCharging = batteryDO.getStatus() == BatteryManager.BATTERY_STATUS_CHARGING; - progressBar.setCharging(isCharging); + gauge.setFlow(flowOf(batteryDO.getStatus())); } final FragmentManager fragmentManager = getSupportFragmentManager(); @@ -283,6 +282,21 @@ private void refreshBatteryUi() { } } + /** + * Map the OS battery status onto the gauge's three flow states: charging fills (wave forward), + * full-on-charger idles with a periodic pulse, anything else counts as draining. + */ + private static HorseshoeProgressBar.Flow flowOf(final int batteryStatus) { + switch (batteryStatus) { + case BatteryManager.BATTERY_STATUS_CHARGING: + return HorseshoeProgressBar.Flow.FILLING; + case BatteryManager.BATTERY_STATUS_FULL: + return HorseshoeProgressBar.Flow.FULL; + default: + return HorseshoeProgressBar.Flow.DRAINING; + } + } + /** * Initialize first values and animate the progress bar */ @@ -290,30 +304,16 @@ private void initializeFirstValues() { fillBatteryInfo(); final SharedPreferences sharedPref = PreferenceManager.getDefaultSharedPreferences(this); - final CircularProgressBar progressBar = findViewById(R.id.batteryPercentage); - progressBar.setWarningLevel(sharedPref.getInt(getString(R.string._pref_key_warn_battery_level), 40)); - progressBar.setCriticalLevel(sharedPref.getInt(getString(R.string._pref_key_critical_battery_level), 20)); + final HorseshoeProgressBar gauge = findViewById(R.id.batteryPercentage); + gauge.setThresholds(sharedPref.getInt(getString(R.string._pref_key_critical_battery_level), 20), + sharedPref.getInt(getString(R.string._pref_key_warn_battery_level), 40)); // Keep the in-fly slider in sync with values that may have changed in Settings. syncThresholdSlider(); - progressBar.animateProgressTo(0, batteryPercentage, new CircularProgressBar.ProgressAnimationListener() { - - @Override - public void onAnimationStart() { - // Animation started - } - - @Override - public void onAnimationFinish() { - // Animation finished - } - - @Override - public void onAnimationProgress(final int progress) { - progressBar.setTitle(progress + "%"); - progressBar.setSubTitle(subTitle); - } + gauge.animateLevelTo(batteryPercentage, progress -> { + gauge.setTitle(progress + "%"); + gauge.setStatusText(subTitle); }); } @@ -360,10 +360,8 @@ public void onStopTrackingTouch(@NonNull final RangeSlider slider) { .putInt(getString(R.string._pref_key_warn_battery_level), warning) .apply(); - final CircularProgressBar progressBar = findViewById(R.id.batteryPercentage); - progressBar.setCriticalLevel(critical); - progressBar.setWarningLevel(warning); - progressBar.invalidate(); + final HorseshoeProgressBar gauge = findViewById(R.id.batteryPercentage); + gauge.setThresholds(critical, warning); } }); diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/CircularProgressBar.java b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/CircularProgressBar.java deleted file mode 100644 index 5e200d8..0000000 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/CircularProgressBar.java +++ /dev/null @@ -1,675 +0,0 @@ -/* - * Simple Battery Notifier - * Copyright (C) 2015-2026 Al-Mothafar Al-Hasan - * - * This file is derived from "circular_progress_bar" by Leon Cheng - * (https://github.com/ylyc/circular_progress_bar), Copyright (C) 2013 Leon Cheng, - * originally licensed under the Apache License, Version 2.0. This file has been - * modified from the original (extends ProgressBar with a 270-degree gauge, the - * cpb_* styleable, auto-fitting title/subtitle, pulse/glow animation, and more). - * - * The Apache-2.0-licensed original is used in compliance with that license; a - * copy of the Apache License 2.0 is provided in licenses/Apache-2.0.txt and the - * attribution is recorded in THIRD-PARTY-NOTICES.md. Apache 2.0 is compatible - * with the GNU GPL v3, so the modifications and the combined work are licensed - * under the GNU GPL v3 (see the project LICENSE.md). - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -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/java/com/almothafar/simplebatterynotifier/ui/widget/HorseshoeProgressBar.java b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/HorseshoeProgressBar.java new file mode 100644 index 0000000..b777f2f --- /dev/null +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/widget/HorseshoeProgressBar.java @@ -0,0 +1,621 @@ +/* + * HorseshoeProgressBar - an animated horseshoe-shaped progress gauge for Android. + * Part of Simple Battery Notifier . + * + * 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 . + * + * 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; + +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.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 com.almothafar.simplebatterynotifier.R; + +import java.util.Locale; +import java.util.function.IntConsumer; + +import static java.util.Objects.nonNull; + +/** + * 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 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)}): + *

+ * Waves can be turned off wholesale with {@link #setWaveEnabled(boolean)} for battery-sensitive + * hosts; filling then falls back to a gentle breathing pulse and draining goes still. + *

+ * Performance: on Android 9+ the gauge renders fully hardware-accelerated (arc shadows are + * GPU-capable there); only Android 8 falls back to a software layer. Animation frames that would + * not change a visible pixel are skipped, and the host should call {@link #pauseAnimations()} / + * {@link #resumeAnimations()} from its pause/resume so nothing runs while backgrounded. + *

+ * Reuse: the class is self-contained — to use it in another project, copy this file plus the + * {@code HorseshoeProgressBar} declare-styleable block from {@code attrs.xml}. All colors, text + * sizes, and the accessibility description format are attributes with built-in defaults; nothing + * else in this app is referenced. Written from scratch for this project (issue #144), GPLv3 as + * headed above. + */ +public class HorseshoeProgressBar extends View { + + /** The ring leaves a 90° opening centered at the bottom: the arc runs 135° → 405°. */ + private static final float GAP_DEGREES = 90f; + private static final float ARC_START = 90f + GAP_DEGREES / 2f; + private static final float ARC_SWEEP = 360f - GAP_DEGREES; + + private static final int MAX_LEVEL = 100; + private static final long LEVEL_ANIMATION_MS = 1000; + + /** Wave: width of the traveling highlight band and one full start→end trip per state. */ + private static final float WAVE_BAND_DEGREES = 42f; + private static final long WAVE_TRIP_FILLING_MS = 2400; + private static final long WAVE_TRIP_DRAINING_MS = 8000; + private static final int WAVE_HIGHLIGHT_COLOR = 0x8CFFFFFF; + + /** Breathing: gentle shrink plus a glow that follows the breath; critical is faster and brighter. */ + private static final long BREATH_CYCLE_FILLING_MS = 2000; + private static final long BREATH_CYCLE_CRITICAL_MS = 1500; + private static final float BREATH_MIN_SCALE = 0.95f; + private static final int BREATH_GLOW_ALPHA_FILLING = 40; + private static final int BREATH_GLOW_ALPHA_CRITICAL = 60; + private static final float GLOW_EXTRA_STROKE_DP = 8f; + + /** Full: one gentle pulse per period; the rest of the period draws nothing. */ + private static final long IDLE_PULSE_PERIOD_MS = 3000; + private static final float IDLE_PULSE_ACTIVE_FRACTION = 0.35f; + private static final int IDLE_PULSE_GLOW_ALPHA = 40; + + /** Built-in appearance defaults, all overridable via the gauge* attributes. */ + private static final int DEFAULT_LEVEL_COLOR = 0xFF2E9CB8; + private static final int DEFAULT_WARNING_COLOR = 0xFFF3A712; + private static final int DEFAULT_ALERT_COLOR = 0xFFE23A2E; + private static final int DEFAULT_TRACK_COLOR = 0x338A8A8A; + private static final int DEFAULT_TEXT_COLOR = 0xFFFFFFFF; + private static final String DEFAULT_LEVEL_DESCRIPTION = "Level at %1$d percent"; + + /** The track ring is drawn slightly wider than the level ring, framing it. */ + private static final float TRACK_EXTRA_STROKE_DP = 4f; + + /** Title may span at most this fraction of the ring's inner width before shrinking. */ + private static final float TITLE_MAX_WIDTH_FRACTION = 0.68f; + private static final float STATUS_MAX_CHORD_FRACTION = 0.9f; + + /** Which way the measured value is moving, as far as the gauge cares about it. */ + public enum Flow { FILLING, FULL, DRAINING } + + // What the gauge shows. + private int level; + private int criticalLevel = 20; + private int warningLevel = 40; + private Flow flow = Flow.DRAINING; + private boolean waveEnabled = true; + private String title = ""; + private String statusText = ""; + private String levelDescriptionFormat = DEFAULT_LEVEL_DESCRIPTION; + + // How the gauge looks (resolved once from attrs/theme). + private float strokeWidthPx; + private float titleBaseTextSizePx; + private float statusBaseTextSizePx; + private boolean showShadow; + private int normalColor; + private int warningColor; + private int alertColor; + + private final Paint trackPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint levelPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint wavePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint glowPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint titlePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint statusPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + + // Geometry, derived from the view size in onSizeChanged(). + private final RectF ring = new RectF(); + private final Matrix waveRotation = new Matrix(); + private SweepGradient waveGradient; + + // Live motion state. + private ValueAnimator motionAnimator; + private ValueAnimator levelAnimator; + private float waveTravel; // 0..1 position of the highlight along its trip + private float breathScale = 1f; + private int glowAlpha; + + private enum Motion { NONE, WAVE_FORWARD, WAVE_REVERSE, BREATH_FILLING, BREATH_CRITICAL, PULSE_IDLE } + + private Motion motion = Motion.NONE; + + public HorseshoeProgressBar(final Context context) { + this(context, null); + } + + public HorseshoeProgressBar(final Context context, final AttributeSet attrs) { + this(context, attrs, 0); + } + + public HorseshoeProgressBar(final Context context, final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + // Hardware canvases only learned to draw arc shadows in Android 9 (Skia renderer); + // on Android 8 fall back to a software layer so the shadow still shows up. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + setLayerType(View.LAYER_TYPE_SOFTWARE, null); + } + setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); + resolveAppearance(attrs, defStyleAttr); + announceLevel(); + } + + // ------------------------------------------------------------------ public API + + /** Show the given level (0–100) immediately. */ + public void setLevel(final int level) { + this.level = clampLevel(level); + announceLevel(); + refreshMotion(); + invalidate(); + } + + public int getLevel() { + return level; + } + + /** The big centered text, normally the percentage (e.g. "80%"). */ + public void setTitle(final String title) { + this.title = nonNull(title) ? title : ""; + invalidate(); + } + + /** The smaller line under the title, normally a status label. */ + public void setStatusText(final String statusText) { + this.statusText = nonNull(statusText) ? statusText : ""; + invalidate(); + } + + /** Update both alert thresholds at once; the ring color and motion re-evaluate immediately. */ + public void setThresholds(final int critical, final int warning) { + this.criticalLevel = critical; + this.warningLevel = warning; + refreshMotion(); + invalidate(); + } + + /** Tell the gauge which way the value is moving; the motion (wave/pulse/breath) follows it. */ + public void setFlow(final Flow flow) { + if (this.flow == flow) { + return; + } + this.flow = flow; + refreshMotion(); + invalidate(); + } + + /** + * Waves are on by default; battery-sensitive hosts can turn them off, in which case filling + * falls back to a gentle breathing pulse and draining goes completely still. + */ + public void setWaveEnabled(final boolean waveEnabled) { + if (this.waveEnabled == waveEnabled) { + return; + } + this.waveEnabled = waveEnabled; + refreshMotion(); + invalidate(); + } + + /** + * Animate the ring from empty up to {@code target}, invoking {@code perStep} with each + * intermediate level so the caller can keep companion text in sync. + */ + public void animateLevelTo(final int target, final IntConsumer perStep) { + if (nonNull(levelAnimator)) { + levelAnimator.cancel(); + } + levelAnimator = ValueAnimator.ofInt(0, clampLevel(target)); + levelAnimator.setDuration(LEVEL_ANIMATION_MS); + levelAnimator.setInterpolator(new AccelerateDecelerateInterpolator()); + levelAnimator.addUpdateListener(animation -> { + setLevel((int) animation.getAnimatedValue()); + if (nonNull(perStep)) { + perStep.accept(level); + } + }); + levelAnimator.start(); + } + + /** + * Stop all decorative motion while the host is backgrounded, so the gauge does not keep + * redrawing (and burning battery) when nobody can see it. + */ + public void pauseAnimations() { + stopMotion(); + } + + /** Restart whatever motion the current state calls for; counterpart of pause. */ + public void resumeAnimations() { + refreshMotion(); + } + + // ------------------------------------------------------------------ measurement & geometry + + /** + * The layout fixes one side (usually the height); the gauge takes that as the ring diameter + * and sizes itself square with room for the stroke to sit fully outside the ring bounds. + */ + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + final int hSize = MeasureSpec.getSize(heightMeasureSpec); + final int wSize = MeasureSpec.getSize(widthMeasureSpec); + final int diameter = MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.UNSPECIFIED + ? hSize + : Math.max(wSize, getSuggestedMinimumHeight()); + final int side = diameter + Math.round(2 * strokeWidthPx); + setMeasuredDimension(side, side); + } + + @Override + protected void onSizeChanged(final int w, final int h, final int oldW, final int oldH) { + super.onSizeChanged(w, h, oldW, oldH); + ring.set(strokeWidthPx, strokeWidthPx, w - strokeWidthPx, h - strokeWidthPx); + + // The wave shader is anchored to the view center, so it must be rebuilt on resize. + waveGradient = new SweepGradient(w / 2f, h / 2f, + new int[]{Color.TRANSPARENT, WAVE_HIGHLIGHT_COLOR, Color.TRANSPARENT}, + new float[]{0f, WAVE_BAND_DEGREES / 720f, WAVE_BAND_DEGREES / 360f}); + wavePaint.setShader(waveGradient); + } + + // ------------------------------------------------------------------ drawing + + @Override + protected void onDraw(@NonNull final Canvas canvas) { + super.onDraw(canvas); + + final boolean breathing = isBreathing() && breathScale != 1f; + if (breathing) { + canvas.save(); + canvas.scale(breathScale, breathScale, getWidth() / 2f, getHeight() / 2f); + } + + canvas.drawArc(ring, ARC_START, ARC_SWEEP, false, trackPaint); + + final float litSweep = ARC_SWEEP * level / MAX_LEVEL; + final int ringColor = colorForLevel(); + levelPaint.setColor(ringColor); + if (showShadow) { + levelPaint.setShadowLayer(12f, 2f, 6f, Color.argb(180, 0, 0, 0)); + } else { + levelPaint.clearShadowLayer(); + } + + if (isBreathing() && glowAlpha > 0) { + glowPaint.setColor(ringColor); + glowPaint.setAlpha(glowAlpha); + canvas.drawArc(ring, ARC_START, litSweep, false, glowPaint); + } + + canvas.drawArc(ring, ARC_START, litSweep, false, levelPaint); + + if (isWaving() && litSweep > 0f) { + drawWave(canvas, litSweep); + } + + drawCenterText(canvas); + + if (breathing) { + canvas.restore(); + } + } + + /** + * Slide the highlight band along the lit arc. The band's angular position comes from rotating + * the sweep gradient; drawing is clipped to the lit sweep so the wave never runs past the tip. + * The travel range overshoots by one band width on each side so the band fully enters and exits. + */ + private void drawWave(final Canvas canvas, final float litSweep) { + final float travelRange = litSweep + 2 * WAVE_BAND_DEGREES; + final float bandAngle = ARC_START - WAVE_BAND_DEGREES + waveTravel * travelRange; + waveRotation.setRotate(bandAngle, getWidth() / 2f, getHeight() / 2f); + waveGradient.setLocalMatrix(waveRotation); + canvas.drawArc(ring, ARC_START, litSweep, false, wavePaint); + } + + private void drawCenterText(final Canvas canvas) { + if (title.isEmpty()) { + return; + } + final float centerX = getWidth() / 2f; + final float centerY = getHeight() / 2f; + + // Shrink the title if a wide value (like "100%") would overflow the ring's inner width. + titlePaint.setTextSize(titleBaseTextSizePx); + final float titleLimit = ring.width() * TITLE_MAX_WIDTH_FRACTION; + final float titleWidth = titlePaint.measureText(title); + if (titleWidth > titleLimit) { + titlePaint.setTextSize(titleBaseTextSizePx * titleLimit / titleWidth); + } + final float titleHeight = Math.abs(titlePaint.descent() + titlePaint.ascent()); + final float titleBaseline = statusText.isEmpty() ? centerY + titleHeight / 2f : centerY; + canvas.drawText(title, centerX - titlePaint.measureText(title) / 2f, titleBaseline, titlePaint); + + if (statusText.isEmpty()) { + return; + } + + // The ring narrows below center, so fit the status line to the chord actually available + // at its vertical offset (one title-height below center), inside the ring thickness. + statusPaint.setTextSize(statusBaseTextSizePx); + final float innerRadius = ring.width() / 2f - strokeWidthPx / 2f; + final float chordHalf = titleHeight < innerRadius + ? (float) Math.sqrt(innerRadius * innerRadius - titleHeight * titleHeight) + : 0f; + final float statusLimit = 2f * chordHalf * STATUS_MAX_CHORD_FRACTION; + final float statusWidth = statusPaint.measureText(statusText); + if (statusLimit > 0f && statusWidth > statusLimit) { + statusPaint.setTextSize(statusBaseTextSizePx * statusLimit / statusWidth); + } + canvas.drawText(statusText, centerX - statusPaint.measureText(statusText) / 2f, + titleBaseline + titleHeight, statusPaint); + } + + // ------------------------------------------------------------------ motion control + + /** + * Pick the motion the current state calls for and (re)start its animator only when the kind + * of motion actually changed, so ongoing loops are not restarted on every refresh tick. + */ + private void refreshMotion() { + final Motion wanted = wantedMotion(); + if (wanted == motion && nonNull(motionAnimator) && motionAnimator.isRunning()) { + return; + } + stopMotion(); + motion = wanted; + switch (wanted) { + case WAVE_FORWARD: + startWave(WAVE_TRIP_FILLING_MS, false); + break; + case WAVE_REVERSE: + startWave(WAVE_TRIP_DRAINING_MS, true); + break; + case BREATH_FILLING: + startBreath(BREATH_CYCLE_FILLING_MS, BREATH_GLOW_ALPHA_FILLING); + break; + case BREATH_CRITICAL: + startBreath(BREATH_CYCLE_CRITICAL_MS, BREATH_GLOW_ALPHA_CRITICAL); + break; + case PULSE_IDLE: + startIdlePulse(); + break; + case NONE: + break; + } + } + + private Motion wantedMotion() { + if (!isAttachedToWindow() || level == 0) { + return Motion.NONE; + } + if (flow == Flow.FILLING) { + return waveEnabled ? Motion.WAVE_FORWARD : Motion.BREATH_FILLING; + } + if (flow == Flow.FULL) { + return Motion.PULSE_IDLE; + } + if (level <= criticalLevel) { + return Motion.BREATH_CRITICAL; + } + return waveEnabled ? Motion.WAVE_REVERSE : Motion.NONE; + } + + private void startWave(final long tripMillis, final boolean reversed) { + motionAnimator = ValueAnimator.ofFloat(0f, 1f); + motionAnimator.setDuration(tripMillis); + motionAnimator.setInterpolator(new LinearInterpolator()); + motionAnimator.setRepeatCount(ValueAnimator.INFINITE); + motionAnimator.addUpdateListener(animation -> { + // Quantize the travel so animator ticks that would move the band less than a visible + // step are skipped instead of triggering a redraw for nothing. + final float quantized = Math.round((float) animation.getAnimatedValue() * 512f) / 512f; + final float travel = reversed ? 1f - quantized : quantized; + if (travel == waveTravel) { + return; + } + waveTravel = travel; + invalidate(); + }); + motionAnimator.start(); + } + + /** + * One soft pulse per {@link #IDLE_PULSE_PERIOD_MS}: the breath happens in the first + * {@link #IDLE_PULSE_ACTIVE_FRACTION} of the period and the remainder draws nothing at all — + * the update listener bails out while the visuals are at rest, so a full gauge sitting idle + * costs almost nothing to display. + */ + private void startIdlePulse() { + motionAnimator = ValueAnimator.ofFloat(0f, 1f); + motionAnimator.setDuration(IDLE_PULSE_PERIOD_MS); + motionAnimator.setInterpolator(new LinearInterpolator()); + motionAnimator.setRepeatCount(ValueAnimator.INFINITE); + motionAnimator.addUpdateListener(animation -> { + final float period = (float) animation.getAnimatedValue(); + final float depth = period < IDLE_PULSE_ACTIVE_FRACTION + ? (float) Math.sin(Math.PI * period / IDLE_PULSE_ACTIVE_FRACTION) + : 0f; + final float scale = 1f - (1f - BREATH_MIN_SCALE) * depth; + final int glow = (int) (depth * IDLE_PULSE_GLOW_ALPHA); + if (scale == breathScale && glow == glowAlpha) { + return; + } + breathScale = scale; + glowAlpha = glow; + invalidate(); + }); + motionAnimator.start(); + } + + private void startBreath(final long cycleMillis, final int maxGlowAlpha) { + motionAnimator = ValueAnimator.ofFloat(BREATH_MIN_SCALE, 1f); + motionAnimator.setDuration(cycleMillis); + motionAnimator.setInterpolator(new AccelerateDecelerateInterpolator()); + motionAnimator.setRepeatCount(ValueAnimator.INFINITE); + motionAnimator.setRepeatMode(ValueAnimator.REVERSE); + motionAnimator.addUpdateListener(animation -> { + breathScale = (float) animation.getAnimatedValue(); + final float depth = (breathScale - BREATH_MIN_SCALE) / (1f - BREATH_MIN_SCALE); + glowAlpha = (int) (depth * maxGlowAlpha); + invalidate(); + }); + motionAnimator.start(); + } + + private void stopMotion() { + if (nonNull(motionAnimator)) { + motionAnimator.cancel(); + motionAnimator = null; + } + motion = Motion.NONE; + breathScale = 1f; + glowAlpha = 0; + invalidate(); + } + + private boolean isWaving() { + return motion == Motion.WAVE_FORWARD || motion == Motion.WAVE_REVERSE; + } + + private boolean isBreathing() { + return motion == Motion.BREATH_FILLING || motion == Motion.BREATH_CRITICAL || motion == Motion.PULSE_IDLE; + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + refreshMotion(); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + stopMotion(); + if (nonNull(levelAnimator)) { + levelAnimator.cancel(); + levelAnimator = null; + } + } + + // ------------------------------------------------------------------ appearance & helpers + + private void resolveAppearance(final AttributeSet attrs, final int defStyleAttr) { + final Context context = getContext(); + final float density = getResources().getDisplayMetrics().density; + + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.HorseshoeProgressBar, defStyleAttr, 0); + try { + strokeWidthPx = a.getDimension(R.styleable.HorseshoeProgressBar_gaugeStrokeWidth, 25 * density); + titleBaseTextSizePx = a.getDimension(R.styleable.HorseshoeProgressBar_gaugeTitleTextSize, 64 * density); + statusBaseTextSizePx = a.getDimension(R.styleable.HorseshoeProgressBar_gaugeStatusTextSize, 20 * density); + showShadow = a.getBoolean(R.styleable.HorseshoeProgressBar_gaugeShowShadow, true); + + normalColor = a.getColor(R.styleable.HorseshoeProgressBar_gaugeLevelColor, DEFAULT_LEVEL_COLOR); + warningColor = a.getColor(R.styleable.HorseshoeProgressBar_gaugeWarningColor, DEFAULT_WARNING_COLOR); + alertColor = a.getColor(R.styleable.HorseshoeProgressBar_gaugeAlertColor, DEFAULT_ALERT_COLOR); + trackPaint.setColor(a.getColor(R.styleable.HorseshoeProgressBar_gaugeTrackColor, DEFAULT_TRACK_COLOR)); + titlePaint.setColor(a.getColor(R.styleable.HorseshoeProgressBar_gaugeTitleColor, DEFAULT_TEXT_COLOR)); + statusPaint.setColor(a.getColor(R.styleable.HorseshoeProgressBar_gaugeStatusColor, DEFAULT_TEXT_COLOR)); + + // Localizable "Level at %1$d percent" template for screen readers. + final String description = a.getString(R.styleable.HorseshoeProgressBar_gaugeLevelDescription); + levelDescriptionFormat = nonNull(description) && !description.isEmpty() ? description : DEFAULT_LEVEL_DESCRIPTION; + + // Preview text so the gauge is meaningful in the layout editor. + title = orEmpty(a.getString(R.styleable.HorseshoeProgressBar_gaugeTitle)); + statusText = orEmpty(a.getString(R.styleable.HorseshoeProgressBar_gaugeStatusText)); + } finally { + a.recycle(); + } + + trackPaint.setStyle(Paint.Style.STROKE); + trackPaint.setStrokeWidth(strokeWidthPx + TRACK_EXTRA_STROKE_DP * density); + trackPaint.setStrokeCap(Paint.Cap.ROUND); + + levelPaint.setStyle(Paint.Style.STROKE); + levelPaint.setStrokeWidth(strokeWidthPx); + levelPaint.setStrokeCap(Paint.Cap.ROUND); + + wavePaint.setStyle(Paint.Style.STROKE); + wavePaint.setStrokeWidth(strokeWidthPx); + wavePaint.setStrokeCap(Paint.Cap.ROUND); + + glowPaint.setStyle(Paint.Style.STROKE); + glowPaint.setStrokeWidth(strokeWidthPx + GLOW_EXTRA_STROKE_DP * density); + glowPaint.setStrokeCap(Paint.Cap.ROUND); + + titlePaint.setTypeface(Typeface.create("sans-serif-medium", Typeface.BOLD)); + titlePaint.setShadowLayer(3f, 0f, 2f, Color.argb(50, 0, 0, 0)); + statusPaint.setTypeface(Typeface.create("sans-serif", Typeface.NORMAL)); + } + + private int colorForLevel() { + if (level >= warningLevel) { + return normalColor; + } + if (level > criticalLevel) { + return warningColor; + } + return alertColor; + } + + private void announceLevel() { + setContentDescription(String.format(Locale.getDefault(), levelDescriptionFormat, level)); + } + + private static int clampLevel(final int value) { + return Math.max(0, Math.min(MAX_LEVEL, value)); + } + + private static String orEmpty(final String value) { + return nonNull(value) ? value : ""; + } +} diff --git a/app/src/main/res/layout-land/activity_main.xml b/app/src/main/res/layout-land/activity_main.xml index 23d16e1..19ff9af 100644 --- a/app/src/main/res/layout-land/activity_main.xml +++ b/app/src/main/res/layout-land/activity_main.xml @@ -1,5 +1,4 @@ - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index b1ee3fe..ee78b82 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..87e8c4f 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -1,16 +1,20 @@ - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 4f54e0c..d95e407 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -18,21 +18,18 @@ - - 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. -