From af9885e2b9518565a2dbe4a02b4922d2c262e44f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 15:20:51 +0000 Subject: [PATCH] Combine critical/warning sliders into one range slider (Settings + Home) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the two separate warning/critical SeekBarPreferences with a single two-thumb Material RangeSlider — left thumb = critical, right thumb = warning — shown both in Settings and as an in-fly control on the home screen. - New BatteryRangeSliderPreference (non-persistent) writes each thumb to the existing key_critical_battery_level / key_warn_battery_level keys, so the receiver and gauge read the same values unchanged. - Shared BatteryRangeSliderHelper holds the bounds (10–50), integer step, 5% minimum thumb separation, defaults, and the "N%" label formatter, used by both the preference and the home slider as the single source of truth. - The minimum separation enforces "critical < warning" structurally, retiring the old cross-field Snackbar validation and its error strings. - Neutral thumbs with colored, always-on captions (red Critical / amber Warning) plus the floating value bubble while dragging. - Home slider persists on release and refreshes the gauge live; it re-syncs from preferences on resume so Settings and Home stay in agreement. - Add EN/AR strings and a parameterized unit test for the clamp invariant. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QYVSSGe1as68rQUyUVLhDH --- .../ui/MainActivity.java | 99 ++++++++++ .../fragment/GenericPreferenceFragment.java | 104 +---------- .../preference/BatteryRangeSliderHelper.java | 74 ++++++++ .../BatteryRangeSliderPreference.java | 169 ++++++++++++++++++ app/src/main/res/layout/activity_main.xml | 47 +++++ .../preference_battery_range_slider.xml | 65 +++++++ app/src/main/res/values-ar/strings.xml | 10 +- app/src/main/res/values/attrs.xml | 12 ++ app/src/main/res/values/strings.xml | 12 +- app/src/main/res/xml/pref_general.xml | 31 ++-- .../BatteryRangeSliderHelperTest.java | 85 +++++++++ 11 files changed, 578 insertions(+), 130 deletions(-) create mode 100644 app/src/main/java/com/almothafar/simplebatterynotifier/ui/preference/BatteryRangeSliderHelper.java create mode 100644 app/src/main/java/com/almothafar/simplebatterynotifier/ui/preference/BatteryRangeSliderPreference.java create mode 100644 app/src/main/res/layout/preference_battery_range_slider.xml create mode 100644 app/src/test/java/com/almothafar/simplebatterynotifier/ui/preference/BatteryRangeSliderHelperTest.java 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 9dcacf2..c58f5a4 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/MainActivity.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/MainActivity.java @@ -15,13 +15,17 @@ import android.util.Log; import android.view.Menu; import android.view.MenuItem; +import android.widget.TextView; import android.widget.Toast; import com.almothafar.simplebatterynotifier.ui.fragment.BatteryDetailsFragment; +import com.almothafar.simplebatterynotifier.ui.preference.BatteryRangeSliderHelper; import com.google.android.material.button.MaterialButton; import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.slider.RangeSlider; import com.google.android.material.snackbar.Snackbar; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; import androidx.appcompat.widget.Toolbar; import androidx.core.content.ContextCompat; import androidx.fragment.app.FragmentManager; @@ -33,6 +37,8 @@ import com.almothafar.simplebatterynotifier.service.SystemService; import com.almothafar.simplebatterynotifier.ui.widget.CircularProgressBar; +import java.util.List; + import static java.util.Objects.isNull; import static java.util.Objects.nonNull; @@ -56,6 +62,7 @@ public class MainActivity extends BaseActivity { // UI elements private MaterialButton batteryInsightsButton; + private RangeSlider thresholdSlider; // Self-reposting refresh loop, bound to the foreground lifecycle (started in onPostResume, // stopped in onPause) so it never stacks across resumes or keeps polling in the background. @@ -156,6 +163,9 @@ protected void onCreate(final Bundle savedInstanceState) { // Set up button click listeners batteryInsightsButton.setOnClickListener(v -> openBatteryInsights()); + // Wire the in-fly critical/warning threshold slider (portrait home screen). + setupThresholdSlider(); + // Best-effort: auto-fill the battery design capacity from the device on first run, so the // measured health/capacity works without the user having to look up and type it in (#104). // No-op on devices where the kernel node isn't readable — manual entry still applies there. @@ -269,6 +279,10 @@ private void initializeFirstValues() { 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)); + + // Keep the in-fly slider in sync with values that may have changed in Settings. + syncThresholdSlider(); + progressBar.animateProgressTo(0, batteryPercentage, new CircularProgressBar.ProgressAnimationListener() { @Override @@ -289,6 +303,91 @@ public void onAnimationProgress(final int progress) { }); } + /** + * Wire the in-fly critical/warning threshold slider on the home screen. + *

+ * The control is portrait-only (absent from the landscape layout), so this no-ops when the + * slider isn't present. Captions update live while dragging; the new values are persisted to the + * same preference keys Settings uses — and the gauge refreshed — only when the drag ends. + */ + private void setupThresholdSlider() { + thresholdSlider = findViewById(R.id.thresholdSlider); + if (isNull(thresholdSlider)) { + return; + } + BatteryRangeSliderHelper.configure(thresholdSlider, + BatteryRangeSliderHelper.LEVEL_FROM, + BatteryRangeSliderHelper.LEVEL_TO, + BatteryRangeSliderHelper.MIN_SEPARATION); + + final TextView criticalCaption = findViewById(R.id.thresholdCriticalCaption); + final TextView warningCaption = findViewById(R.id.thresholdWarningCaption); + + thresholdSlider.addOnChangeListener((slider, value, fromUser) -> { + final List values = slider.getValues(); + updateThresholdCaptions(criticalCaption, warningCaption, + Math.round(values.get(0)), Math.round(values.get(1))); + }); + + thresholdSlider.addOnSliderTouchListener(new RangeSlider.OnSliderTouchListener() { + @Override + public void onStartTrackingTouch(@NonNull final RangeSlider slider) { + // No-op: applied on release. + } + + @Override + public void onStopTrackingTouch(@NonNull final RangeSlider slider) { + final List values = slider.getValues(); + final int critical = Math.round(values.get(0)); + final int warning = Math.round(values.get(1)); + + PreferenceManager.getDefaultSharedPreferences(MainActivity.this).edit() + .putInt(getString(R.string._pref_key_critical_battery_level), critical) + .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(); + } + }); + + syncThresholdSlider(); + } + + /** + * Load the persisted critical/warning levels onto the in-fly slider and captions, clamping into + * the slider's bounds so values set elsewhere (e.g. Settings) are always displayable. + */ + private void syncThresholdSlider() { + if (isNull(thresholdSlider)) { + return; + } + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + final int critical = prefs.getInt(getString(R.string._pref_key_critical_battery_level), + BatteryRangeSliderHelper.DEFAULT_CRITICAL); + final int warning = prefs.getInt(getString(R.string._pref_key_warn_battery_level), + BatteryRangeSliderHelper.DEFAULT_WARNING); + final int[] pair = BatteryRangeSliderHelper.clampPair(critical, warning, + BatteryRangeSliderHelper.LEVEL_FROM, BatteryRangeSliderHelper.LEVEL_TO, + BatteryRangeSliderHelper.MIN_SEPARATION); + + thresholdSlider.setValues((float) pair[0], (float) pair[1]); + updateThresholdCaptions(findViewById(R.id.thresholdCriticalCaption), + findViewById(R.id.thresholdWarningCaption), pair[0], pair[1]); + } + + private void updateThresholdCaptions(final TextView criticalCaption, final TextView warningCaption, + final int critical, final int warning) { + if (nonNull(criticalCaption)) { + criticalCaption.setText(getString(R.string.battery_range_caption_critical, critical)); + } + if (nonNull(warningCaption)) { + warningCaption.setText(getString(R.string.battery_range_caption_warning, warning)); + } + } + /** * Request notification permission if needed (Android 13+) */ diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/GenericPreferenceFragment.java b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/GenericPreferenceFragment.java index 47db4ec..44c4f74 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/GenericPreferenceFragment.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/GenericPreferenceFragment.java @@ -10,7 +10,6 @@ import android.os.Bundle; import android.text.TextUtils; import android.util.Log; -import android.view.View; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; @@ -33,7 +32,6 @@ import com.almothafar.simplebatterynotifier.util.TemperatureUtils; import com.almothafar.simplebatterynotifier.ui.preference.TimePickerPreference; import com.almothafar.simplebatterynotifier.ui.preference.TimePickerPreferenceDialogFragmentCompat; -import com.google.android.material.snackbar.Snackbar; import java.util.Set; @@ -52,10 +50,6 @@ public class GenericPreferenceFragment extends PreferenceFragmentCompat private static final String TAG = "GenericPreferenceFrag"; - // Default battery level values (from pref_general.xml) - private static final int DEFAULT_WARNING_BATTERY_LEVEL = 40; - private static final int DEFAULT_CRITICAL_BATTERY_LEVEL = 20; - private RingtonePreference currentRingtonePreference; private ActivityResultLauncher ringtonePickerLauncher; @@ -350,16 +344,14 @@ private void updateEditTextPreferenceSummary(final EditTextPreference editTextPr /** * Update summary for SeekBarPreference *

- * Adds percentage suffix for battery level preferences. + * Adds the temperature-unit suffix for the high-temperature threshold. */ private void updateSeekBarPreferenceSummary(final SeekBarPreference seekBarPref) { final String key = seekBarPref.getKey(); if (isNull(key)) { return; } - if (isBatteryLevelPreference(key)) { - seekBarPref.setSummary(seekBarPref.getValue() + "%"); - } else if (key.equals(getString(R.string._pref_key_high_temperature_threshold))) { + if (key.equals(getString(R.string._pref_key_high_temperature_threshold))) { seekBarPref.setSummary(seekBarPref.getValue() + temperatureUnitSuffix(TemperatureUtils.isFahrenheit(requireContext()))); } } @@ -423,17 +415,6 @@ private void updateTimePickerPreferenceSummary(final SharedPreferences sharedPre } } - /** - * Check if a preference key is for battery level configuration - * - * @param key The preference key to check - * @return true if the key is for warning or critical battery level - */ - private boolean isBatteryLevelPreference(final String key) { - return key.equals(getString(R.string._pref_key_warn_battery_level)) || - key.equals(getString(R.string._pref_key_critical_battery_level)); - } - /** * Initialize summaries for all preferences *

@@ -471,11 +452,6 @@ protected void initPreferencesSummary(final SharedPreferences sharedPreferences, if (p instanceof final RingtonePreference ringtonePref) { setupRingtonePreferenceListener(ringtonePref); } - - // Set up validation for battery level preferences - if (p instanceof SeekBarPreference && isBatteryLevelPreference(p.getKey())) { - setupBatteryLevelValidation(sharedPreferences, p); - } } } @@ -495,80 +471,4 @@ private void setupRingtonePreferenceListener(final RingtonePreference ringtonePr }); } - /** - * Set up validation for battery level SeekBarPreferences - *

- * Ensures warning level is always higher than critical level and vice versa. - * Shows accessible error message via Snackbar if validation fails. - * - * @param sharedPreferences The SharedPreferences containing current values - * @param pref The battery level preference to validate - */ - private void setupBatteryLevelValidation(final SharedPreferences sharedPreferences, final Preference pref) { - pref.setOnPreferenceChangeListener((preference, newValue) -> { - if (newValue instanceof Integer) { - final int value = (Integer) newValue; - final String key = preference.getKey(); - - // Validate the new value - final String errorMessage = validateBatteryLevel(key, value, sharedPreferences); - if (nonNull(errorMessage)) { - showValidationError(errorMessage); - return false; // Reject the change - } - - // Update summary with new value - preference.setSummary(value + "%"); - } - return true; - }); - } - - /** - * Validate battery level change - *

- * Ensures warning level is always higher than critical level. - * - * @param key The preference key being changed - * @param newValue The new value to validate - * @param sharedPreferences The SharedPreferences containing current values - * @return Error message if validation fails, null if valid - */ - private String validateBatteryLevel(final String key, final int newValue, final SharedPreferences sharedPreferences) { - if (key.equals(getString(R.string._pref_key_warn_battery_level))) { - // Changing warning level - ensure it's higher than critical - final int criticalLevel = sharedPreferences.getInt( - getString(R.string._pref_key_critical_battery_level), - DEFAULT_CRITICAL_BATTERY_LEVEL); - if (newValue <= criticalLevel) { - return getString(R.string.error_warning_level_too_low, criticalLevel); - } - } else if (key.equals(getString(R.string._pref_key_critical_battery_level))) { - // Changing critical level - ensure it's lower than warning - final int warningLevel = sharedPreferences.getInt( - getString(R.string._pref_key_warn_battery_level), - DEFAULT_WARNING_BATTERY_LEVEL); - if (newValue >= warningLevel) { - return getString(R.string.error_critical_level_too_high, warningLevel); - } - } - return null; // Valid - } - - /** - * Show validation error message using Snackbar for accessibility - *

- * Snackbar is preferred over Toast because: - * - It's accessible to TalkBack users - * - It's more visible and can contain actions - * - It follows Material Design guidelines - * - * @param message The error message to display - */ - private void showValidationError(final String message) { - final View view = getView(); - if (nonNull(view)) { - Snackbar.make(view, message, Snackbar.LENGTH_SHORT).show(); - } - } } diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/preference/BatteryRangeSliderHelper.java b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/preference/BatteryRangeSliderHelper.java new file mode 100644 index 0000000..7312e14 --- /dev/null +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/preference/BatteryRangeSliderHelper.java @@ -0,0 +1,74 @@ +package com.almothafar.simplebatterynotifier.ui.preference; + +import com.almothafar.simplebatterynotifier.R; +import com.google.android.material.slider.RangeSlider; + +/** + * Shared configuration for the combined critical/warning battery-level {@link RangeSlider}. + *

+ * The same two-thumb control appears in two places — the settings screen + * ({@link BatteryRangeSliderPreference}) and the in-fly control on the home screen — so the + * bounds, step, minimum thumb separation, and the "{@code N%}" label formatting live here as the + * single source of truth. The left thumb is the critical level, the right thumb is the warning + * level, and {@link #MIN_SEPARATION} guarantees critical always stays below warning. + */ +public final class BatteryRangeSliderHelper { + + /** Lowest value the critical thumb can reach (the combined track's start). */ + public static final int LEVEL_FROM = 10; + /** Highest value the warning thumb can reach (the combined track's end). */ + public static final int LEVEL_TO = 50; + /** Minimum gap kept between the critical and warning thumbs, in percent. */ + public static final int MIN_SEPARATION = 5; + /** Default critical level, matching the historical {@code SeekBarPreference} default. */ + public static final int DEFAULT_CRITICAL = 20; + /** Default warning level, matching the historical {@code SeekBarPreference} default. */ + public static final int DEFAULT_WARNING = 40; + + private BatteryRangeSliderHelper() { + // Utility class + } + + /** + * Apply the shared bounds, integer step, minimum thumb separation, and a "{@code N%}" label + * formatter to a slider. Does not set the thumb values — callers set those after clamping via + * {@link #clampPair(int, int, int, int, int)}. + * + * @param slider the range slider to configure + * @param from combined track start (critical floor) + * @param to combined track end (warning ceiling) + * @param minSeparation minimum gap between the two thumbs, in value units + */ + public static void configure(final RangeSlider slider, final int from, final int to, final int minSeparation) { + slider.setValueFrom(from); + slider.setValueTo(to); + slider.setStepSize(1f); + slider.setMinSeparationValue(minSeparation); + // Show the dragged thumb's value as a whole percentage (e.g. "20%") instead of "20.0". + slider.setLabelFormatter(value -> + slider.getContext().getString(R.string.battery_level_percent, Math.round(value))); + } + + /** + * Clamp a (critical, warning) pair into {@code [from, to]} while keeping them at least + * {@code minSeparation} apart, so persisted or corrupted values can always be shown on the + * slider without tripping its validation. + * + * @return a two-element array {@code {critical, warning}} + */ + public static int[] clampPair(final int critical, final int warning, + final int from, final int to, final int minSeparation) { + int low = clamp(critical, from, to); + int high = clamp(warning, from, to); + if (high - low < minSeparation) { + // Push the warning thumb up first; if that hits the ceiling, pull critical back down. + high = Math.min(to, low + minSeparation); + low = Math.max(from, high - minSeparation); + } + return new int[]{low, high}; + } + + private static int clamp(final int value, final int lo, final int hi) { + return Math.max(lo, Math.min(hi, value)); + } +} diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/preference/BatteryRangeSliderPreference.java b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/preference/BatteryRangeSliderPreference.java new file mode 100644 index 0000000..6139da7 --- /dev/null +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/preference/BatteryRangeSliderPreference.java @@ -0,0 +1,169 @@ +package com.almothafar.simplebatterynotifier.ui.preference; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.preference.Preference; +import androidx.preference.PreferenceViewHolder; + +import com.almothafar.simplebatterynotifier.R; +import com.google.android.material.slider.RangeSlider; + +import java.util.List; + +import static java.util.Objects.isNull; +import static java.util.Objects.nonNull; + +/** + * A settings preference that combines the critical and warning battery levels into a single + * two-thumb {@link RangeSlider} — the left thumb is the critical level, the right thumb is the + * warning level. It replaces the two separate {@code SeekBarPreference}s and enforces + * "critical < warning" structurally via the slider's minimum thumb separation (so the old + * cross-field validation is no longer needed). + *

+ * The preference is non-persistent: it writes each thumb to its own existing SharedPreferences key + * ({@code criticalKey} / {@code warningKey}) so {@code BatteryLevelReceiver} and the home gauge keep + * reading the same keys unchanged. Bounds, step, separation, and label formatting come from + * {@link BatteryRangeSliderHelper}. + */ +public class BatteryRangeSliderPreference extends Preference { + + private String criticalKey; + private String warningKey; + private int from = BatteryRangeSliderHelper.LEVEL_FROM; + private int to = BatteryRangeSliderHelper.LEVEL_TO; + private int minSeparation = BatteryRangeSliderHelper.MIN_SEPARATION; + private int criticalDefault = BatteryRangeSliderHelper.DEFAULT_CRITICAL; + private int warningDefault = BatteryRangeSliderHelper.DEFAULT_WARNING; + + public BatteryRangeSliderPreference(final Context context, final AttributeSet attrs, + final int defStyleAttr, final int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(attrs); + } + + public BatteryRangeSliderPreference(final Context context, final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(attrs); + } + + public BatteryRangeSliderPreference(final Context context, final AttributeSet attrs) { + super(context, attrs); + init(attrs); + } + + public BatteryRangeSliderPreference(final Context context) { + super(context); + init(null); + } + + private void init(final AttributeSet attrs) { + setLayoutResource(R.layout.preference_battery_range_slider); + // The slider handles its own touch; the row itself is not clickable and we persist by hand. + setSelectable(false); + setPersistent(false); + + // Sensible defaults; overridable from XML so the bounds/keys stay declarative. + criticalKey = getContext().getString(R.string._pref_key_critical_battery_level); + warningKey = getContext().getString(R.string._pref_key_warn_battery_level); + + if (nonNull(attrs)) { + final TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable.BatteryRangeSliderPreference); + criticalKey = orDefault(a.getString(R.styleable.BatteryRangeSliderPreference_criticalKey), criticalKey); + warningKey = orDefault(a.getString(R.styleable.BatteryRangeSliderPreference_warningKey), warningKey); + from = a.getInt(R.styleable.BatteryRangeSliderPreference_sliderFrom, from); + to = a.getInt(R.styleable.BatteryRangeSliderPreference_sliderTo, to); + minSeparation = a.getInt(R.styleable.BatteryRangeSliderPreference_sliderMinSeparation, minSeparation); + criticalDefault = a.getInt(R.styleable.BatteryRangeSliderPreference_criticalDefault, criticalDefault); + warningDefault = a.getInt(R.styleable.BatteryRangeSliderPreference_warningDefault, warningDefault); + a.recycle(); + } + } + + @Override + public void onBindViewHolder(@NonNull final PreferenceViewHolder holder) { + super.onBindViewHolder(holder); + + final RangeSlider slider = (RangeSlider) holder.findViewById(R.id.battery_range_slider); + final TextView criticalCaption = (TextView) holder.findViewById(R.id.battery_range_critical_caption); + final TextView warningCaption = (TextView) holder.findViewById(R.id.battery_range_warning_caption); + if (isNull(slider)) { + return; + } + + BatteryRangeSliderHelper.configure(slider, from, to, minSeparation); + + // Drop any listeners left over from a previous bind (this view can be recycled) before + // seeding values, so the initial setValues() can't fire a stale listener. + slider.clearOnChangeListeners(); + slider.clearOnSliderTouchListeners(); + + final int[] pair = readClampedValues(); + slider.setValues((float) pair[0], (float) pair[1]); + updateCaptions(criticalCaption, warningCaption, pair[0], pair[1]); + + // Update the captions live while dragging; persist only when the drag ends to avoid a burst + // of writes and readers seeing half-applied intermediate values. + slider.addOnChangeListener((s, value, fromUser) -> { + final int[] current = currentValues(s); + updateCaptions(criticalCaption, warningCaption, current[0], current[1]); + }); + + slider.addOnSliderTouchListener(new RangeSlider.OnSliderTouchListener() { + @Override + public void onStartTrackingTouch(@NonNull final RangeSlider s) { + // No-op: we persist on release. + } + + @Override + public void onStopTrackingTouch(@NonNull final RangeSlider s) { + final int[] current = currentValues(s); + persist(current[0], current[1]); + } + }); + } + + /** + * Read the persisted critical/warning values (falling back to the defaults) and clamp them into + * the slider's bounds and separation so they can always be applied safely. + */ + private int[] readClampedValues() { + final SharedPreferences prefs = getSharedPreferences(); + final int critical = nonNull(prefs) ? prefs.getInt(criticalKey, criticalDefault) : criticalDefault; + final int warning = nonNull(prefs) ? prefs.getInt(warningKey, warningDefault) : warningDefault; + return BatteryRangeSliderHelper.clampPair(critical, warning, from, to, minSeparation); + } + + private void persist(final int critical, final int warning) { + final SharedPreferences prefs = getSharedPreferences(); + if (nonNull(prefs)) { + prefs.edit() + .putInt(criticalKey, critical) + .putInt(warningKey, warning) + .apply(); + } + } + + private void updateCaptions(final TextView criticalCaption, final TextView warningCaption, + final int critical, final int warning) { + if (nonNull(criticalCaption)) { + criticalCaption.setText(getContext().getString(R.string.battery_range_caption_critical, critical)); + } + if (nonNull(warningCaption)) { + warningCaption.setText(getContext().getString(R.string.battery_range_caption_warning, warning)); + } + } + + private static int[] currentValues(final RangeSlider slider) { + final List values = slider.getValues(); + return new int[]{Math.round(values.get(0)), Math.round(values.get(1))}; + } + + private static String orDefault(final String value, final String fallback) { + return nonNull(value) ? value : fallback; + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 96491d0..1445b9b 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -63,6 +63,53 @@ android:paddingBottom="@dimen/content_navigation_bar_padding" android:background="@android:color/white"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index c364de2..fe4344f 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -50,6 +50,12 @@ مستوى التحذير يبدأ من المستوى الحرج يبدأ من + + + مستويات تنبيه البطارية + حرج %1$d%% + تحذير %1$d%% + حرج %1$d%% • تحذير %2$d%% وحدة الحرارة إطفاء تشغيل @@ -103,10 +109,6 @@ شحن اعتيادي بدأ البطارية تشحن بواسطة %1$s - - يجب أن يكون مستوى التحذير أعلى من المستوى الحرج (%d%%) - يجب أن يكون المستوى الحرج أقل من مستوى التحذير (%d%%) - البطارية عند %1$d بالمئة : diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 6fd44ce..3dba99a 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -12,4 +12,16 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cfab66f..c545710 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -52,6 +52,13 @@ Warning Level Start From Critical Level Start From + + + Battery Alert Levels + Critical %1$d%% + Warning %1$d%% + Critical %1$d%% • Warning %2$d%% + %1$d%% Temperature Unit OFF ON @@ -217,6 +224,7 @@ key_warn_battery_level key_critical_battery_level + key_battery_level_range key_notifications_sticky key_temperatures_unit key_notifications_vibrate @@ -240,10 +248,6 @@ 23:30 06:30 - - Warning level must be higher than critical level (%d%%) - Critical level must be lower than warning level (%d%%) - content://settings/system/notification_sound diff --git a/app/src/main/res/xml/pref_general.xml b/app/src/main/res/xml/pref_general.xml index 40dc34d..3fa2a60 100644 --- a/app/src/main/res/xml/pref_general.xml +++ b/app/src/main/res/xml/pref_general.xml @@ -35,26 +35,17 @@ android:title="@string/pref_cat_title_thresholds" app:iconSpaceReserved="false"> - - - diff --git a/app/src/test/java/com/almothafar/simplebatterynotifier/ui/preference/BatteryRangeSliderHelperTest.java b/app/src/test/java/com/almothafar/simplebatterynotifier/ui/preference/BatteryRangeSliderHelperTest.java new file mode 100644 index 0000000..969d9ed --- /dev/null +++ b/app/src/test/java/com/almothafar/simplebatterynotifier/ui/preference/BatteryRangeSliderHelperTest.java @@ -0,0 +1,85 @@ +package com.almothafar.simplebatterynotifier.ui.preference; + +import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +import java.util.Arrays; +import java.util.Collection; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for the pure (context-free) clamping logic in {@link BatteryRangeSliderHelper}, which + * guarantees the critical/warning pair is always inside the slider's bounds and at least + * {@link BatteryRangeSliderHelper#MIN_SEPARATION} apart — the invariant that replaces the old + * cross-field validation. + */ +@RunWith(Enclosed.class) +public class BatteryRangeSliderHelperTest { + + private static final int FROM = BatteryRangeSliderHelper.LEVEL_FROM; + private static final int TO = BatteryRangeSliderHelper.LEVEL_TO; + private static final int SEP = BatteryRangeSliderHelper.MIN_SEPARATION; + + /** + * {@link BatteryRangeSliderHelper#clampPair(int, int, int, int, int)} maps representative + * (critical, warning) inputs — valid, out-of-range, inverted, and too-close — to the expected + * clamped pair. + */ + @RunWith(Parameterized.class) + public static class ClampPair { + + @Parameter(0) public int critical; + @Parameter(1) public int warning; + @Parameter(2) public int expectedCritical; + @Parameter(3) public int expectedWarning; + + @Parameters(name = "clampPair({0},{1}) = [{2},{3}]") + public static Collection data() { + return Arrays.asList(new Object[][]{ + {20, 40, 20, 40}, // already-valid defaults, untouched + {10, 50, 10, 50}, // full range, untouched + {5, 40, 10, 40}, // critical below floor -> raised to floor + {20, 60, 20, 50}, // warning above ceiling -> lowered to ceiling + {30, 30, 30, 35}, // equal -> warning pushed up to keep the gap + {40, 20, 40, 45}, // inverted -> warning pushed above critical + {48, 49, 45, 50}, // too close near the ceiling -> both shifted down + {2, 4, 10, 15}, // both below floor + {60, 70, 45, 50}, // both above ceiling + }); + } + + @Test + public void clampsToExpectedPair() { + final int[] result = BatteryRangeSliderHelper.clampPair(critical, warning, FROM, TO, SEP); + assertEquals("critical", expectedCritical, result[0]); + assertEquals("warning", expectedWarning, result[1]); + } + } + + /** + * Whatever the input — including corrupted values far outside the range — the result must always + * be a usable pair the slider can accept without tripping its own validation. + */ + public static class Invariants { + + @Test + public void everyInputYieldsAValidPair() { + for (int critical = -20; critical <= 80; critical++) { + for (int warning = -20; warning <= 80; warning++) { + final int[] r = BatteryRangeSliderHelper.clampPair(critical, warning, FROM, TO, SEP); + final String at = "(" + critical + "," + warning + ")"; + assertTrue("critical in range at " + at, r[0] >= FROM && r[0] <= TO); + assertTrue("warning in range at " + at, r[1] >= FROM && r[1] <= TO); + assertTrue("separation kept at " + at, r[1] - r[0] >= SEP); + assertTrue("critical below warning at " + at, r[0] < r[1]); + } + } + } + } +}