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..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 @@ -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()); @@ -456,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 @@ -477,57 +513,83 @@ 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 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). * * @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 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 + * 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_remaining); + 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 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 + * @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 +654,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..c17ea7c 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -32,6 +32,22 @@ التيار الوقت حتى الاكتمال + + الوقت المتبقي + + + + مزيد من المعلومات + جارٍ حساب المعدل + يُحسب معدل الشحن/الاستهلاك كمتوسط لقراءات الدقائق القليلة الماضية، لذا يحتاج إلى دقيقة أو دقيقتين بعد توصيل الشاحن أو فصله أو فتح التطبيق قبل أن يعرض قيمة موثوقة. + التقدير غير جاهز + يُشتق هذا التقدير الزمني من معدل الشحن/الاستهلاك، لذا يظهر بعد استقرار ذلك المعدل. كما يُخفى قرب اكتمال الشحن حيث يكون التقدير بلا معنى. + التيار المباشر غير متوفر + قد يستغرق التيار اللحظي وقتًا حتى يستقر، وبعض الأجهزة لا تُبلغ عنه إطلاقًا. عند توفره يظهر هنا بالملّي أمبير (mA). + + السعة التصميمية غير محددة + هذه هي السعة الأصلية للبطارية حسب مواصفات الشركة المصنّعة، بالملّي أمبير-ساعة. حدّدها من شاشة تحليلات البطارية للحصول على قياس فعلي لصحة البطارية — وحتى ذلك الحين تُقدَّر الصحة من دورات الشحن. على البطارية مقبس طاقة diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4f4629e..14eac8b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -34,6 +34,22 @@ Current Time to full + + Time remaining + + + + 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 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)); } }