From 1491278faedc9852a73c19719c4461597faa1125 Mon Sep 17 00:00:00 2001 From: Al-Mothafar Al-Hasan Date: Sat, 18 Jul 2026 13:45:11 +0300 Subject: [PATCH 1/3] Details table: keep Rate/Time/Current rows always present, no show/hide (#188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rate, time-estimate and current rows used to appear and disappear depending on state (rate warm-up, time-to-full charging-only + <99% gate, current dipping below the display floor), which shifted the table and read as janky. They are now permanent: the label follows the charge direction and a not-yet-available value shows a "—" placeholder with a tappable info icon that explains why (rate still settling, current not always reported, estimate needs the rate first). In steady state the labels are constant, so values update in place with no flicker; a label only changes on plug/unplug. - New "Time to empty" while discharging (BatteryRateTracker.estimateMinutesToEmpty), the mirror of Time to full; formatTimeToFull renamed to the direction-neutral formatDuration. - Placeholder is a dash ("—") deliberately: every worded option is much longer in Arabic and the value column has broken layout before (#175); the info dialog carries the explanation where length is free. - New neutral ic_info affordance (distinct from the amber #94 warning); applyValueDecorations clears every decoration on rebind so a row can flip between real value and placeholder in place. - New strings drafted in EN + AR (values-ar). Closes #188 Co-Authored-By: Claude Fable 5 --- .../service/BatteryRateTracker.java | 28 ++- .../ui/fragment/BatteryDetailsFragment.java | 199 ++++++++++++------ app/src/main/res/drawable/ic_info.xml | 12 ++ app/src/main/res/values-ar/strings.xml | 13 ++ app/src/main/res/values/strings.xml | 13 ++ .../service/BatteryRateTrackerTest.java | 44 +++- 6 files changed, 235 insertions(+), 74 deletions(-) create mode 100644 app/src/main/res/drawable/ic_info.xml diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/service/BatteryRateTracker.java b/app/src/main/java/com/almothafar/simplebatterynotifier/service/BatteryRateTracker.java index 747cca1..a476284 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/service/BatteryRateTracker.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/service/BatteryRateTracker.java @@ -488,16 +488,36 @@ public static int estimateMinutesToFull(final int level, final int ratePercentPe } /** - * Formats a time-to-full estimate as a compact {@code "~1h 20m"} / {@code "~45m"}, with Western digits + * Estimated minutes until empty from the smoothed drain rate (#188): the discharge mirror of + * {@link #estimateMinutesToFull}, {@code level / ratePercentPerHour} hours. Same capacity-free linear + * projection, so it works where the charge counter is unreliable (Kirin), and the same non-goal on + * precision — the OS owns the accurate figure. Callers gate on discharging + a trustworthy rate. + * Pure so it is testable. + * + * @param level current battery level (0-100) + * @param ratePercentPerHour smoothed drain-rate magnitude in %/h + * + * @return estimated minutes to empty, or 0 when not computable (non-positive rate, or already empty) + */ + public static int estimateMinutesToEmpty(final int level, final int ratePercentPerHour) { + if (ratePercentPerHour <= 0 || level <= 0) { + return 0; + } + return (int) Math.round(level * 60.0 / ratePercentPerHour); + } + + /** + * Formats a duration estimate as a compact {@code "~1h 20m"} / {@code "~45m"}, with Western digits * in every locale (#96). The units stay Latin (h/m), matching {@code battery_rate_value} and - * {@code design_capacity_value}. + * {@code design_capacity_value}. Direction-agnostic — used for both time-to-full and time-to-empty (#188). * * @param context Application context - * @param totalMinutes estimated minutes to full (from {@link #estimateMinutesToFull}); must be > 0 + * @param totalMinutes estimated minutes (from {@link #estimateMinutesToFull} / + * {@link #estimateMinutesToEmpty}); must be > 0 * * @return the formatted duration string */ - public static String formatTimeToFull(final Context context, final int totalMinutes) { + public static String formatDuration(final Context context, final int totalMinutes) { final int hours = totalMinutes / 60; final int minutes = totalMinutes % 60; if (hours > 0) { diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/BatteryDetailsFragment.java b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/BatteryDetailsFragment.java index 4229838..97c01d1 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/BatteryDetailsFragment.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/BatteryDetailsFragment.java @@ -70,16 +70,21 @@ public class BatteryDetailsFragment extends Fragment { private int cellPaddingTop; // #94: when this device's charge counter can't be trusted, the Capacity row shows "Unknown" with a - // tappable info icon instead of a misleading mAh figure. Computed in fillBatteryInfo, applied by - // the row binder to just the capacity row. + // tappable amber-warning icon instead of a misleading mAh figure. Computed in fillBatteryInfo, applied + // by the row binder to just the capacity row. private boolean capacityUnreliable; private String capacityLabel; - // #108: the drain-rate row's label, and the colour applied to its value cell (amber near the user's - // limit, red at/above it, while discharging; 0 = no special colour). Both set in fillBatteryInfo and - // applied by the row binder to just that row, mirroring the capacity special-case above. - private String rateLabel; - private int rateValueColor; + // Per-label value-cell decorations for this refresh, applied by the row binder (#161/#188). Keyed by + // the row's current label (unique per row), so a row can be re-bound in place while its decoration + // still tracks its live state. + // - valueColorByLabel: the drain-rate colour, amber near the user's limit / red at-or-above it while + // discharging (#108); absent = the default value colour. + // - pendingInfoByLabel: the rate/time/current rows stay present even when their value isn't ready yet + // (#188); such a row shows the "—" placeholder plus a tappable info icon, and this maps the label to + // its {dialogTitleRes, dialogMessageRes}. + private final Map valueColorByLabel = new LinkedHashMap<>(); + private final Map pendingInfoByLabel = new LinkedHashMap<>(); /** * Default constructor required for fragment instantiation @@ -158,26 +163,32 @@ private void refreshDetailsTable(final View view) { syncRows(tableLayout, rowViews, valuesMap, new RowBinder() { @Override public TableRow createRow(final String label, final CharSequence value) { - return createTableRow(view, label, value, cellPadding, cellPaddingTop, isUnreliableRow(label), valueColorFor(label)); + return createTableRow(view, label, value, cellPadding, cellPaddingTop, + isUnreliableRow(label), valueColorFor(label), pendingInfoFor(label)); } @Override public void bindValue(final TableRow row, final String label, final CharSequence value) { final TextView valueView = (TextView) row.getChildAt(VALUE_CELL_INDEX); valueView.setText(value); - applyValueDecorations(valueView, isUnreliableRow(label), valueColorFor(label)); + applyValueDecorations(valueView, isUnreliableRow(label), valueColorFor(label), pendingInfoFor(label)); } }); } - /** Only the capacity row gets the "unreliable reading" info affordance (#94). */ + /** Only the capacity row gets the amber "unreliable reading" affordance (#94). */ private boolean isUnreliableRow(final String label) { return capacityUnreliable && label.equals(capacityLabel); } - /** Only the drain-rate row is coloured (amber/red near the limit while discharging) — #108. */ + /** The drain-rate row's amber/red colour near the limit while discharging (#108); 0 = default. */ private int valueColorFor(final String label) { - return (rateValueColor != 0 && label.equals(rateLabel)) ? rateValueColor : 0; + return valueColorByLabel.getOrDefault(label, 0); + } + + /** The {titleRes, messageRes} for a row currently showing the pending placeholder (#188), or null. */ + private int[] pendingInfoFor(final String label) { + return pendingInfoByLabel.get(label); } /** @@ -299,14 +310,15 @@ public void onAnimationEnd(final Animator animation) { * @param value The value text * @param cellPadding The horizontal cell padding * @param cellPaddingTop The top cell padding - * @param valueUnreliable When true, decorate the value cell with a tappable "unreliable reading" icon + * @param valueUnreliable When true, decorate the value cell with a tappable amber "unreliable reading" icon * @param valueColor Colour for the value text, or 0 to keep the default value colour (#108) + * @param pendingInfo {dialogTitleRes, dialogMessageRes} for a placeholder row's info icon, or null (#188) * * @return The created table row */ private TableRow createTableRow(final View view, final String label, final CharSequence value, final int cellPadding, final int cellPaddingTop, - final boolean valueUnreliable, final int valueColor) { + final boolean valueUnreliable, final int valueColor, final int[] pendingInfo) { final TableRow row = new TableRow(view.getContext()); final TableRow.LayoutParams layoutParams = new TableRow.LayoutParams( TableRow.LayoutParams.MATCH_PARENT, TableRow.LayoutParams.WRAP_CONTENT, 1.0f); @@ -318,7 +330,7 @@ private TableRow createTableRow(final View view, final String label, final CharS final TextView textViewLabel = createLabelTextView(view, label, cellPadding, cellPaddingTop); final TextView textViewSep = createSeparatorTextView(view); - final TextView textViewValue = createValueTextView(view, value, cellPadding, cellPaddingTop, valueUnreliable, valueColor); + final TextView textViewValue = createValueTextView(view, value, cellPadding, cellPaddingTop, valueUnreliable, valueColor, pendingInfo); // Add views in logical order (label -> separator -> value) // RTL languages will automatically reverse the visual order @@ -380,6 +392,7 @@ private TextView createSeparatorTextView(final View view) { * @param unreliable When true, append a tappable amber info icon that opens the "unreliable * reading" explanation (#94) * @param valueColor Colour for the value text, or 0 to keep the default value colour (#108) + * @param pendingInfo {dialogTitleRes, dialogMessageRes} for a placeholder row's info icon, or null (#188) * * @return The created TextView */ @@ -388,7 +401,8 @@ private TextView createValueTextView(final View view, final int cellPadding, final int cellPaddingTop, final boolean unreliable, - final int valueColor) { + final int valueColor, + final int[] pendingInfo) { final TextView textView = new TextView(view.getContext()); textView.setTextAppearance(R.style.DefaultTextStyle); textView.setText(text); @@ -397,30 +411,43 @@ private TextView createValueTextView(final View view, textView.setGravity(Gravity.START); textView.setTextDirection(View.TEXT_DIRECTION_LOCALE); textView.setPaddingRelative(cellPadding, 0, 0, cellPaddingTop); - applyValueDecorations(textView, unreliable, valueColor); + applyValueDecorations(textView, unreliable, valueColor, pendingInfo); return textView; } /** - * (Re-)apply the per-refresh decorations of a value cell: the rate colour (#108) and the capacity - * row's tappable "unreliable reading" affordance (#94). Called at creation and on every in-place - * rebind (#161), so it also has to clear both when the row's state changes back. + * (Re-)apply the per-refresh decorations of a value cell: the rate colour (#108), the capacity row's + * amber "unreliable reading" affordance (#94), and the neutral info affordance on a rate/time/current + * row that is showing the "—" placeholder because its value isn't ready yet (#188). Called at creation + * and on every in-place rebind (#161), so it also has to clear all of them when a row's state + * changes back (e.g. the rate finishes warming up and the placeholder becomes a real number). * - * @param textView the value cell - * @param unreliable when true, decorate with the tappable amber info icon; when false, remove it - * @param valueColor colour for the value text, or 0 for the default value colour + * @param textView the value cell + * @param unreliable when true, decorate with the tappable amber warning icon (capacity, #94) + * @param valueColor colour for the value text, or 0 for the default value colour + * @param pendingInfo {dialogTitleRes, dialogMessageRes} for the neutral info icon (#188), or null */ - private void applyValueDecorations(final TextView textView, final boolean unreliable, final int valueColor) { + private void applyValueDecorations(final TextView textView, final boolean unreliable, final int valueColor, + final int[] pendingInfo) { textView.setTextColor(valueColor != 0 ? valueColor : GeneralHelper.getColor(getResources(), R.color.battery_details_value_color)); + final int iconPadding = (int) (6 * getResources().getDisplayMetrics().density); if (unreliable) { // #94: amber warning affordance after the value; tap to learn why the reading can't be trusted. // Same mark as the insights health figure, so the two screens read consistently. textView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_warning_amber, 0); - textView.setCompoundDrawablePadding((int) (6 * getResources().getDisplayMetrics().density)); + textView.setCompoundDrawablePadding(iconPadding); textView.setContentDescription(getString(R.string.battery_reading_unreliable_cd)); textView.setOnClickListener(v -> showCapacityUnreliableDialog()); + } else if (nonNull(pendingInfo)) { + // #188: neutral info affordance after the "—" placeholder; tap to learn why the value isn't ready. + textView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_info, 0); + textView.setCompoundDrawablePadding(iconPadding); + textView.setContentDescription(getString(R.string.battery_value_info_cd)); + final int titleRes = pendingInfo[0]; + final int messageRes = pendingInfo[1]; + textView.setOnClickListener(v -> showInfoDialog(titleRes, messageRes)); } else { textView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0); textView.setContentDescription(null); @@ -436,11 +463,14 @@ private void applyValueDecorations(final TextView textView, final boolean unreli */ private void fillBatteryInfo(final View view) { valuesMap = new LinkedHashMap<>(); + valueColorByLabel.clear(); + pendingInfoByLabel.clear(); - // #108: live charge/drain rate and signed current at the very top, each shown only when its - // reading is trustworthy. Recording here also feeds the smoothing window from the foreground - // refresh (the other feed is the battery broadcast) without any polling timer of our own. - addRateRows(view); + // #108/#188: live charge/drain rate, time estimate and signed current at the very top. These rows + // stay put across refreshes; a not-yet-ready value shows a placeholder + info icon rather than + // hiding. Recording here also feeds the smoothing window from the foreground refresh (the other + // feed is the battery broadcast) without any polling timer of our own. + addLiveRows(view); valuesMap.put(getResources().getString(R.string.technology), batteryDO.getTechnology()); @@ -477,57 +507,82 @@ private void fillBatteryInfo(final View view) { } /** - * Adds the drain/charge rate and signed-current rows at the top of the table (#108). - *

- * Each row appears only when its reading is trustworthy (independent gating like #94): the rate hides - * during the brief post-unplug warm-up or a static level, the current hides when the device doesn't - * report a plausible mA. The label flips between "Drain rate" and "Charge rate" with direction, and - * the rate value is coloured amber/red near the user's limit while discharging (see {@link #rateColor}). + * Adds the three "live" rows at the top of the table — rate, time estimate and signed current (#108, + * #124, #188). Unlike the rest, these three are always present: rather than hiding when a value + * isn't ready (which made rows appear/disappear and shift the table), a not-yet-available value shows a + * "—" placeholder with a tappable info icon explaining why. The labels follow the charge direction — + * "Charge rate"/"Drain rate" and "Time to full"/"Time to empty" — while "Current" is constant; because + * the labels are stable within a direction, steady-state refreshes update the values in place with no + * flicker (a label only changes on plug/unplug, when the whole context changes anyway). * * @param view The fragment view */ - private void addRateRows(final View view) { - rateLabel = null; - rateValueColor = 0; - + private void addLiveRows(final View view) { final BatteryRateTracker.BatteryRate rate = BatteryRateTracker.record(view.getContext(), batteryDO); + final boolean charging = rate.charging(); + // Rate row: real %/h (coloured amber/red near the limit while discharging), else the placeholder. + final String rateLabel = getString(charging ? R.string.charge_rate : R.string.drain_rate); if (rate.hasRate()) { - rateLabel = getResources().getString(rate.charging() ? R.string.charge_rate : R.string.drain_rate); valuesMap.put(rateLabel, BatteryRateTracker.formatRateValue(view.getContext(), rate.percentPerHour())); - rateValueColor = rateColor(view.getContext(), rate); + final int color = rateColor(view.getContext(), rate); + if (color != 0) { + valueColorByLabel.put(rateLabel, color); + } + } else { + putPendingRow(rateLabel, R.string.battery_rate_pending_dialog_title, R.string.battery_rate_pending_dialog_message); } - addTimeToFullRow(view, rate); + + addTimeRow(view, rate, charging); + + // Current row: the signed mA (with the windowed-average second line), else the placeholder. + final String currentLabel = getString(R.string.battery_current); if (rate.hasCurrent()) { - valuesMap.put(getResources().getString(R.string.battery_current), currentValueText(view, rate)); + valuesMap.put(currentLabel, currentValueText(view, rate)); + } else { + putPendingRow(currentLabel, R.string.battery_current_pending_dialog_title, R.string.battery_current_pending_dialog_message); } } /** - * Adds the estimated time-to-full row directly below the charge rate it is derived from (#124). Shown - * only while charging, with a trustworthy rate, and below the taper top (level < 99%) — hidden - * otherwise, so the user never sees a garbage "0m" or a wildly optimistic figure right as the charge - * tapers. The estimate is a rough linear projection (see - * {@link BatteryRateTracker#estimateMinutesToFull}); precision is a non-goal — the OS owns the accurate - * number. Reuses the already-computed {@code rate}; only the level is read here. + * Adds the estimated-time row directly below the rate it is derived from: "Time to full" while charging, + * "Time to empty" while discharging (#124/#188). A rough capacity-free linear projection (see + * {@link BatteryRateTracker#estimateMinutesToFull} / {@link BatteryRateTracker#estimateMinutesToEmpty}); + * precision is a non-goal — the OS owns the accurate figure. When the rate isn't ready, or the estimate + * degenerates (already full/empty, or right at the charge taper where a figure would mislead), the row + * stays put and shows the placeholder rather than vanishing. Reuses the already-computed {@code rate}; + * only the level is read here. * - * @param view The fragment view - * @param rate The already-computed rate for this refresh + * @param view The fragment view + * @param rate The already-computed rate for this refresh + * @param charging Whether the battery is charging (picks the label and the projection direction) */ - private void addTimeToFullRow(final View view, final BatteryRateTracker.BatteryRate rate) { - if (!rate.charging() || !rate.hasRate()) { - return; - } - final int level = batteryDO.getBatteryPercentageInt(); - if (level >= 99) { - return; - } - final int minutes = BatteryRateTracker.estimateMinutesToFull(level, rate.percentPerHour()); - if (minutes <= 0) { - return; + private void addTimeRow(final View view, final BatteryRateTracker.BatteryRate rate, final boolean charging) { + final String label = getString(charging ? R.string.time_to_full : R.string.time_to_empty); + if (rate.hasRate()) { + final int level = batteryDO.getBatteryPercentageInt(); + final int minutes = charging + ? BatteryRateTracker.estimateMinutesToFull(level, rate.percentPerHour()) + : BatteryRateTracker.estimateMinutesToEmpty(level, rate.percentPerHour()); + if (minutes > 0) { + valuesMap.put(label, BatteryRateTracker.formatDuration(view.getContext(), minutes)); + return; + } } - valuesMap.put(getResources().getString(R.string.time_to_full), - BatteryRateTracker.formatTimeToFull(view.getContext(), minutes)); + putPendingRow(label, R.string.battery_time_pending_dialog_title, R.string.battery_time_pending_dialog_message); + } + + /** + * Places a "live" row in its not-yet-ready state (#188): the "—" placeholder value plus a tappable info + * icon whose dialog explains why. Keeps the row present so the table doesn't reflow. + * + * @param label the row label + * @param titleRes the info dialog's title resource + * @param messageRes the info dialog's message resource + */ + private void putPendingRow(final String label, final int titleRes, final int messageRes) { + valuesMap.put(label, getString(R.string.battery_value_pending)); + pendingInfoByLabel.put(label, new int[]{titleRes, messageRes}); } /** @@ -592,4 +647,20 @@ private void showCapacityUnreliableDialog() { .setPositiveButton(android.R.string.ok, null) .show(); } + + /** + * Explains why a "live" row's value isn't ready yet (#188), opened from the neutral info icon on a + * placeholder rate/time/current row. The wording is row-specific (rate settling, estimate not ready, + * current unavailable). + * + * @param titleRes the dialog title resource + * @param messageRes the dialog message resource + */ + private void showInfoDialog(final int titleRes, final int messageRes) { + new MaterialAlertDialogBuilder(requireContext()) + .setTitle(titleRes) + .setMessage(messageRes) + .setPositiveButton(android.R.string.ok, null) + .show(); + } } diff --git a/app/src/main/res/drawable/ic_info.xml b/app/src/main/res/drawable/ic_info.xml new file mode 100644 index 0000000..990cde0 --- /dev/null +++ b/app/src/main/res/drawable/ic_info.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index be9da8c..ca17357 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -32,6 +32,19 @@ التيار الوقت حتى الاكتمال + + الوقت حتى الفراغ + + + + لماذا لا تظهر هذه القيمة بعد + جارٍ حساب المعدل + يُحسب معدل الشحن/الاستهلاك كمتوسط لقراءات الدقائق القليلة الماضية، لذا يحتاج إلى دقيقة أو دقيقتين بعد توصيل الشاحن أو فصله أو فتح التطبيق قبل أن يعرض قيمة موثوقة. + التقدير غير جاهز + يُشتق هذا التقدير الزمني من معدل الشحن/الاستهلاك، لذا يظهر بعد استقرار ذلك المعدل. كما يُخفى قرب اكتمال الشحن حيث يكون التقدير بلا معنى. + التيار المباشر غير متوفر + قد يستغرق التيار اللحظي وقتًا حتى يستقر، وبعض الأجهزة لا تُبلغ عنه إطلاقًا. عند توفره يظهر هنا بالملّي أمبير (mA). على البطارية مقبس طاقة diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4f4629e..d9777a2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -34,6 +34,19 @@ Current Time to full + + Time to empty + + + + Why this value isn\'t shown yet + Rate is still settling + The charge/drain rate is averaged over the last few minutes of readings, so it needs a minute or two after you plug in, unplug, or open the app before it can show a reliable figure. + Estimate not ready + This time estimate is projected from the charge/drain rate, so it appears once that rate has settled. It\'s also hidden very close to a full charge, where the estimate would be meaningless. + Live current unavailable + The instantaneous current can take a moment to settle, and some devices don\'t report it at all. When it\'s available it shows here in milliamps (mA). On Battery AC Charger diff --git a/app/src/test/java/com/almothafar/simplebatterynotifier/service/BatteryRateTrackerTest.java b/app/src/test/java/com/almothafar/simplebatterynotifier/service/BatteryRateTrackerTest.java index 84b5c3c..1e9732a 100644 --- a/app/src/test/java/com/almothafar/simplebatterynotifier/service/BatteryRateTrackerTest.java +++ b/app/src/test/java/com/almothafar/simplebatterynotifier/service/BatteryRateTrackerTest.java @@ -419,13 +419,45 @@ public void matchesExpected() { } /** - * {@link BatteryRateTracker#formatTimeToFull}: the "~1h 20m" / "~45m" rendering and its Western-digit + * {@link BatteryRateTracker#estimateMinutesToEmpty}: the discharge mirror (#188) — {@code level/rate} + * hours, with the same non-computable guards. Boundaries: empty (0%), a zero/negative rate, rounding. + */ + @RunWith(Parameterized.class) + public static class EstimateMinutesToEmpty { + + @Parameter(0) public String label; + @Parameter(1) public int level; + @Parameter(2) public int ratePercentPerHour; + @Parameter(3) public int expectedMinutes; + + @Parameters(name = "{0}") + public static Collection data() { + return Arrays.asList(new Object[][]{ + {"50% at 30%/h -> 100m", 50, 30, 100}, // 50/30 h = 1h40m + {"90% at 60%/h -> 90m", 90, 60, 90}, // 90/60 h + {"20% at 10%/h -> 120m", 20, 10, 120}, // 20/10 h = 2h + {"rounds to nearest minute", 45, 40, 68}, // 45/40 h = 67.5m -> 68 + {"one point left at 60%/h -> 1m", 1, 60, 1}, + {"zero rate not computable", 50, 0, 0}, + {"negative rate not computable", 50, -5, 0}, + {"already empty not computable", 0, 30, 0}, + }); + } + + @Test + public void matchesExpected() { + assertEquals(label, expectedMinutes, BatteryRateTracker.estimateMinutesToEmpty(level, ratePercentPerHour)); + } + } + + /** + * {@link BatteryRateTracker#formatDuration}: the "~1h 20m" / "~45m" rendering and its Western-digit * guarantee (#96). Context-dependent (string resources), so it runs under Robolectric — unlike the pure * math above; mirrors how the other Context-backed formatters would be exercised. */ @RunWith(RobolectricTestRunner.class) @Config(sdk = 34) - public static class FormatTimeToFull { + public static class FormatDuration { private Context context; @@ -436,17 +468,17 @@ public void setUp() { @Test public void hoursAndMinutes() { - assertEquals("~1h 20m", BatteryRateTracker.formatTimeToFull(context, 80)); + assertEquals("~1h 20m", BatteryRateTracker.formatDuration(context, 80)); } @Test public void minutesOnly() { - assertEquals("~45m", BatteryRateTracker.formatTimeToFull(context, 45)); + assertEquals("~45m", BatteryRateTracker.formatDuration(context, 45)); } @Test public void wholeHoursStillShowMinutes() { - assertEquals("~2h 0m", BatteryRateTracker.formatTimeToFull(context, 120)); + assertEquals("~2h 0m", BatteryRateTracker.formatDuration(context, 120)); } @Test @@ -454,7 +486,7 @@ public void wholeHoursStillShowMinutes() { public void keepsWesternDigitsUnderArabicLocale() { // values-ar keeps the "~%1$sh %2$sm" shape and String.valueOf keeps the digits 0-9 (#96), so the // Arabic render is identical — a regression to %d here would surface Arabic-Indic digits. - assertEquals("~1h 20m", BatteryRateTracker.formatTimeToFull(context, 80)); + assertEquals("~1h 20m", BatteryRateTracker.formatDuration(context, 80)); } } From ec925f96c59aeb9c1501106e44542adae4426474 Mon Sep 17 00:00:00 2001 From: Al-Mothafar Al-Hasan Date: Sat, 18 Jul 2026 13:53:01 +0300 Subject: [PATCH 2/3] =?UTF-8?q?Rename=20the=20discharge=20time=20label=20t?= =?UTF-8?q?o=20"Time=20remaining"=20/=20"=D8=A7=D9=84=D9=88=D9=82=D8=AA=20?= =?UTF-8?q?=D8=A7=D9=84=D9=85=D8=AA=D8=A8=D9=82=D9=8A"=20(#188)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Time to empty" / "الوقت حتى الفراغ" reads as vague in Arabic — فراغ (empty/ void/space/finish) doesn't make clear "empty of what". "Time remaining" / "الوقت المتبقي" is unambiguous. Renamed the string key time_to_empty -> time_remaining to match. Co-Authored-By: Claude Fable 5 --- .../ui/fragment/BatteryDetailsFragment.java | 6 +++--- app/src/main/res/values-ar/strings.xml | 4 ++-- app/src/main/res/values/strings.xml | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/BatteryDetailsFragment.java b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/BatteryDetailsFragment.java index 97c01d1..ac125d4 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/BatteryDetailsFragment.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/BatteryDetailsFragment.java @@ -511,7 +511,7 @@ private void fillBatteryInfo(final View view) { * #124, #188). Unlike the rest, these three are always present: rather than hiding when a value * isn't ready (which made rows appear/disappear and shift the table), a not-yet-available value shows a * "—" placeholder with a tappable info icon explaining why. The labels follow the charge direction — - * "Charge rate"/"Drain rate" and "Time to full"/"Time to empty" — while "Current" is constant; because + * "Charge rate"/"Drain rate" and "Time to full"/"Time remaining" — while "Current" is constant; because * the labels are stable within a direction, steady-state refreshes update the values in place with no * flicker (a label only changes on plug/unplug, when the whole context changes anyway). * @@ -546,7 +546,7 @@ private void addLiveRows(final View view) { /** * Adds the estimated-time row directly below the rate it is derived from: "Time to full" while charging, - * "Time to empty" while discharging (#124/#188). A rough capacity-free linear projection (see + * "Time remaining" while discharging (#124/#188). A rough capacity-free linear projection (see * {@link BatteryRateTracker#estimateMinutesToFull} / {@link BatteryRateTracker#estimateMinutesToEmpty}); * precision is a non-goal — the OS owns the accurate figure. When the rate isn't ready, or the estimate * degenerates (already full/empty, or right at the charge taper where a figure would mislead), the row @@ -558,7 +558,7 @@ private void addLiveRows(final View view) { * @param charging Whether the battery is charging (picks the label and the projection direction) */ private void addTimeRow(final View view, final BatteryRateTracker.BatteryRate rate, final boolean charging) { - final String label = getString(charging ? R.string.time_to_full : R.string.time_to_empty); + final String label = getString(charging ? R.string.time_to_full : R.string.time_remaining); if (rate.hasRate()) { final int level = batteryDO.getBatteryPercentageInt(); final int minutes = charging diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index ca17357..883484a 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -32,8 +32,8 @@ التيار الوقت حتى الاكتمال - - الوقت حتى الفراغ + + الوقت المتبقي diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d9777a2..3808e2b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -34,8 +34,8 @@ Current Time to full - - Time to empty + + Time remaining From 90f89a89e56bb3201d0d929239b87999d256025a Mon Sep 17 00:00:00 2001 From: Al-Mothafar Al-Hasan Date: Sat, 18 Jul 2026 13:56:18 +0300 Subject: [PATCH 3/3] Design capacity row: always present with a placeholder when unset (#188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Design capacity row only existed once a value was set, so it appeared out of nowhere the first time — the same show/hide the rest of this change removes. It's now always present: when no design capacity is entered it shows the "—" placeholder + info icon, whose dialog explains what it is and that it's set in Battery Insights. Also generalized the info-icon content description to "More information" (it now serves both not-yet-ready values and this not-set config row). Co-Authored-By: Claude Fable 5 --- .../ui/fragment/BatteryDetailsFragment.java | 17 ++++++++++++----- app/src/main/res/values-ar/strings.xml | 5 ++++- app/src/main/res/values/strings.xml | 5 ++++- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/BatteryDetailsFragment.java b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/BatteryDetailsFragment.java index ac125d4..10f89d4 100644 --- a/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/BatteryDetailsFragment.java +++ b/app/src/main/java/com/almothafar/simplebatterynotifier/ui/fragment/BatteryDetailsFragment.java @@ -486,12 +486,18 @@ private void fillBatteryInfo(final View view) { capacityLabel = getResources().getString(R.string.capacity); valuesMap.put(capacityLabel, capacityText); - // Show the user-entered design (rated) capacity when set (issue #32) + // Design (rated) capacity (issue #32). Always present (#188): when the user hasn't entered one it + // shows the placeholder + info icon pointing to Battery Insights, rather than the row appearing + // only once a value is set. + final String designCapacityLabel = getString(R.string.design_capacity); final int designCapacity = BatteryHealthTracker.getDesignCapacity(view.getContext()); if (designCapacity > 0) { // Western digits (0-9) in every locale — see design_capacity_value / #96. - valuesMap.put(getResources().getString(R.string.design_capacity), - getResources().getString(R.string.design_capacity_value, String.valueOf(designCapacity))); + valuesMap.put(designCapacityLabel, + getString(R.string.design_capacity_value, String.valueOf(designCapacity))); + } else { + putPendingRow(designCapacityLabel, + R.string.battery_design_capacity_unset_dialog_title, R.string.battery_design_capacity_unset_dialog_message); } // Add charge cycles from the battery health tracker - positioned right after capacity. The @@ -573,8 +579,9 @@ private void addTimeRow(final View view, final BatteryRateTracker.BatteryRate ra } /** - * Places a "live" row in its not-yet-ready state (#188): the "—" placeholder value plus a tappable info - * icon whose dialog explains why. Keeps the row present so the table doesn't reflow. + * Places a row in its no-value state (#188): the "—" placeholder plus a tappable info icon whose + * dialog explains why. Keeps the row present so the table doesn't reflow — used both for a live value + * that isn't ready yet (rate/time/current) and for optional config that isn't set (design capacity). * * @param label the row label * @param titleRes the info dialog's title resource diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 883484a..c17ea7c 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -38,13 +38,16 @@ - لماذا لا تظهر هذه القيمة بعد + مزيد من المعلومات جارٍ حساب المعدل يُحسب معدل الشحن/الاستهلاك كمتوسط لقراءات الدقائق القليلة الماضية، لذا يحتاج إلى دقيقة أو دقيقتين بعد توصيل الشاحن أو فصله أو فتح التطبيق قبل أن يعرض قيمة موثوقة. التقدير غير جاهز يُشتق هذا التقدير الزمني من معدل الشحن/الاستهلاك، لذا يظهر بعد استقرار ذلك المعدل. كما يُخفى قرب اكتمال الشحن حيث يكون التقدير بلا معنى. التيار المباشر غير متوفر قد يستغرق التيار اللحظي وقتًا حتى يستقر، وبعض الأجهزة لا تُبلغ عنه إطلاقًا. عند توفره يظهر هنا بالملّي أمبير (mA). + + السعة التصميمية غير محددة + هذه هي السعة الأصلية للبطارية حسب مواصفات الشركة المصنّعة، بالملّي أمبير-ساعة. حدّدها من شاشة تحليلات البطارية للحصول على قياس فعلي لصحة البطارية — وحتى ذلك الحين تُقدَّر الصحة من دورات الشحن. على البطارية مقبس طاقة diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3808e2b..14eac8b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -40,13 +40,16 @@ - Why this value isn\'t shown yet + More information Rate is still settling The charge/drain rate is averaged over the last few minutes of readings, so it needs a minute or two after you plug in, unplug, or open the app before it can show a reliable figure. Estimate not ready This time estimate is projected from the charge/drain rate, so it appears once that rate has settled. It\'s also hidden very close to a full charge, where the estimate would be meaningless. Live current unavailable The instantaneous current can take a moment to settle, and some devices don\'t report it at all. When it\'s available it shows here in milliamps (mA). + + Design capacity not set + This is your battery\'s original rated capacity from the manufacturer, in mAh. Set it in Battery Insights to unlock a measured battery-health figure — until then, health is estimated from charge cycles. On Battery AC Charger