Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -289,6 +303,91 @@ public void onAnimationProgress(final int progress) {
});
}

/**
* Wire the in-fly critical/warning threshold slider on the home screen.
* <p>
* 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<Float> 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<Float> 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+)
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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<Intent> ringtonePickerLauncher;

Expand Down Expand Up @@ -350,16 +344,14 @@ private void updateEditTextPreferenceSummary(final EditTextPreference editTextPr
/**
* Update summary for SeekBarPreference
* <p>
* 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())));
}
}
Expand Down Expand Up @@ -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
* <p>
Expand Down Expand Up @@ -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);
}
}
}

Expand All @@ -495,80 +471,4 @@ private void setupRingtonePreferenceListener(final RingtonePreference ringtonePr
});
}

/**
* Set up validation for battery level SeekBarPreferences
* <p>
* 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
* <p>
* 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
* <p>
* 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();
}
}
}
Original file line number Diff line number Diff line change
@@ -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}.
* <p>
* 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));
}
}
Loading