MetricsSamples: add experimental UI visualisation paths

- (de-)activate via Settings / Developer Options / Experimental metrics UI
- UI currently uses this only for TrainingAcuteLoad, TrainingChronicLoad and RestingMetabolicRate
- Garmin devices are the only ones that actually record these values in the database
This commit is contained in:
Thomas Kuehne
2026-05-29 19:28:32 +00:00
parent 23cf885c13
commit 97281bcb38
8 changed files with 155 additions and 29 deletions
@@ -1,5 +1,6 @@
/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
Gobbetti, Dikay900, José Rebelo, Pavel Elagin, Petr Vaněk, walkjivefly
/* Copyright (C) 2015-2026 Andreas Shimokawa, Carsten Pfeiffer, Daniele
Gobbetti, Dikay900, José Rebelo, Pavel Elagin, Petr Vaněk, walkjivefly,
Thomas Kuehne
This file is part of Gadgetbridge.
@@ -48,7 +49,9 @@ import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBFragment;
import nodomain.freeyourgadget.gadgetbridge.database.DBAccess;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.GenericMetricSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.MetricSample;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
/**
@@ -422,4 +425,9 @@ public abstract class AbstractChartFragment<D extends ChartsData> extends Abstra
getChartsHost().setDateInfo(DateTimeUtils.formatDateRange(from, to, dateFlags));
}
}
public boolean supportsMetrics(MetricSample.Metric metric) {
final GBDevice device = getChartsHost().getDevice();
return GenericMetricSampleProvider.supportsMetrics(device, metric);
}
}
@@ -1,4 +1,4 @@
/* Copyright (C) 2024-2025 a0z, José Rebelo, Martin.JM, Thomas Kuehne
/* Copyright (C) 2024-2026 a0z, José Rebelo, Martin.JM, Thomas Kuehne
This file is part of Gadgetbridge.
@@ -16,6 +16,9 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.charts;
import static nodomain.freeyourgadget.gadgetbridge.devices.GenericMetricSampleProvider.getLatestMetricSample;
import static nodomain.freeyourgadget.gadgetbridge.model.MetricSample.Metric.GENERIC_RESTING_METABOLIC_RATE;
import android.os.Bundle;
import android.util.TypedValue;
import android.view.LayoutInflater;
@@ -30,6 +33,8 @@ import androidx.core.content.ContextCompat;
import com.github.mikephil.charting.charts.Chart;
import org.apache.commons.lang3.EnumUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.text.SimpleDateFormat;
import java.util.Calendar;
@@ -48,9 +53,11 @@ import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
import nodomain.freeyourgadget.gadgetbridge.model.MetricSample;
import nodomain.freeyourgadget.gadgetbridge.model.RestingMetabolicRateSample;
public class CaloriesDailyFragment extends AbstractChartFragment<CaloriesDailyFragment.CaloriesData> {
private static final Logger LOG = LoggerFactory.getLogger(CaloriesDailyFragment.class);
private ImageView caloriesGauge;
private TextView dateView;
@@ -67,6 +74,7 @@ public class CaloriesDailyFragment extends AbstractChartFragment<CaloriesDailyFr
TOTAL_CALORIES_SEGMENT
}
private GaugeViewMode gaugeViewMode;
private boolean metricMetabolicResting;
@Override
public void onCreate(final Bundle savedInstanceState) {
@@ -95,6 +103,12 @@ public class CaloriesDailyFragment extends AbstractChartFragment<CaloriesDailyFr
getChartsHost().enableSwipeRefresh(scrollY == 0);
});
metricMetabolicResting = GBApplication.getPrefs().experimentalMetrics()
&& supportsMetrics(GENERIC_RESTING_METABOLIC_RATE);
if (metricMetabolicResting) {
LOG.info("using experimental MetricSample for resting metabolic rate");
}
caloriesGauge = rootView.findViewById(R.id.calories_gauge);
dateView = rootView.findViewById(R.id.date_view);
caloriesResting = rootView.findViewById(R.id.calories_resting);
@@ -124,6 +138,9 @@ public class CaloriesDailyFragment extends AbstractChartFragment<CaloriesDailyFr
}
public boolean supportsActiveCalories() {
if (metricMetabolicResting) {
return true;
}
final GBDevice device = getChartsHost().getDevice();
return device.getDeviceCoordinator().supportsActiveCalories(device);
}
@@ -167,8 +184,15 @@ public class CaloriesDailyFragment extends AbstractChartFragment<CaloriesDailyFr
String formattedDate = new SimpleDateFormat("E, MMM dd").format(date);
dateView.setText(formattedDate);
List<? extends ActivitySample> samples = getActivitySamples(db, device, startTs, endTs);
RestingMetabolicRateSample metabolicRate = getRestingMetabolicRate(db, device);
if (metabolicRate == null) {
final Integer restingMetabolicRate;
if (metricMetabolicResting) {
MetricSample sample = getLatestMetricSample(db, device, GENERIC_RESTING_METABOLIC_RATE);
restingMetabolicRate = (sample == null) ? null : (int) sample.getMetricScore();
} else {
RestingMetabolicRateSample sample = getRestingMetabolicRate(db, device);
restingMetabolicRate = (sample == null) ? null : sample.getRestingMetabolicRate();
}
if (restingMetabolicRate == null) {
return new CaloriesData(0, 0, 0, 0);
}
int totalBurnt;
@@ -179,7 +203,7 @@ public class CaloriesDailyFragment extends AbstractChartFragment<CaloriesDailyFr
if (sameDay) {
passedDayProportion = (double) (calendar.getTimeInMillis() - day.getTimeInMillis()) / (24L * 60 * 60 * 1000);
}
int restingBurnt = (int) (metabolicRate.getRestingMetabolicRate() * passedDayProportion);
int restingBurnt = (int) (restingMetabolicRate * passedDayProportion);
for (int i = 0; i <= samples.size() - 1; i++) {
ActivitySample sample = samples.get(i);
@@ -191,7 +215,7 @@ public class CaloriesDailyFragment extends AbstractChartFragment<CaloriesDailyFr
activeBurnt = activeBurnt / 1000;
totalBurnt = restingBurnt + activeBurnt;
return new CaloriesData(totalBurnt, activeBurnt, restingBurnt, metabolicRate.getRestingMetabolicRate());
return new CaloriesData(totalBurnt, activeBurnt, restingBurnt, restingMetabolicRate);
}
@Override
@@ -1,5 +1,24 @@
/* Copyright (C) 2025-2026 trentsuzuki, Thomas Kuehne
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.charts;
import static nodomain.freeyourgadget.gadgetbridge.devices.GenericMetricSampleProvider.getLatestMetricSample;
import static nodomain.freeyourgadget.gadgetbridge.model.MetricSample.Metric.GENERIC_RESTING_METABOLIC_RATE;
import android.app.Activity;
import org.slf4j.Logger;
@@ -19,6 +38,7 @@ import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityAmount;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityAmounts;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.MetricSample;
import nodomain.freeyourgadget.gadgetbridge.model.RestingMetabolicRateSample;
import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue;
@@ -47,8 +67,14 @@ abstract class CaloriesFragment<T extends ChartsData> extends AbstractChartFragm
}
protected List<CaloriesFragment.CaloriesDay> getMyCaloriesDaysData(DBHandler db, Calendar day, GBDevice device) {
RestingMetabolicRateSample metabolicRate = getRestingMetabolicRate(db, device);
int restingCalories = metabolicRate.getRestingMetabolicRate();
int restingCalories;
if (GBApplication.getPrefs().experimentalMetrics() && supportsMetrics(GENERIC_RESTING_METABOLIC_RATE)) {
MetricSample sample = getLatestMetricSample(db, device, GENERIC_RESTING_METABOLIC_RATE);
restingCalories = (sample == null) ? 0 : (int) sample.getMetricScore();
} else {
RestingMetabolicRateSample metabolicRate = getRestingMetabolicRate(db, device);
restingCalories = metabolicRate.getRestingMetabolicRate();
}
day = (Calendar) day.clone(); // do not modify the caller's argument
day.add(Calendar.DATE, -TOTAL_DAYS + 1);
@@ -1,4 +1,4 @@
/* Copyright (C) 2025 a0z, Thomas Kuehne
/* Copyright (C) 2025-2026 a0z, Thomas Kuehne
This file is part of Gadgetbridge.
@@ -16,6 +16,11 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.charts;
import static nodomain.freeyourgadget.gadgetbridge.devices.GenericMetricSampleProvider.getLatestMetricSampleBefore;
import static nodomain.freeyourgadget.gadgetbridge.devices.GenericMetricSampleProvider.getMetricSamples;
import static nodomain.freeyourgadget.gadgetbridge.model.MetricSample.Metric.GENERIC_TRAINING_LOAD_ACUTE;
import static nodomain.freeyourgadget.gadgetbridge.model.MetricSample.Metric.GENERIC_TRAINING_LOAD_CHRONIC;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Color;
@@ -67,6 +72,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericTrainingLoadAcuteSample;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericTrainingLoadChronicSample;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.MetricSample;
import nodomain.freeyourgadget.gadgetbridge.model.WorkoutLoadSample;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
@@ -92,6 +98,8 @@ public class LoadFragment extends AbstractChartFragment<LoadFragment.LoadsData>
protected int TEXT_COLOR;
protected int LOAD_COLOR;
private boolean metricTrainingLoad;
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.fragment_load, container, false);
@@ -104,6 +112,13 @@ public class LoadFragment extends AbstractChartFragment<LoadFragment.LoadsData>
dailyLoadChart = rootView.findViewById(R.id.daily_load_chart);
thisWeekTotal = rootView.findViewById(R.id.this_week_total);
lastWeekTotal = rootView.findViewById(R.id.last_week_total);
metricTrainingLoad = GBApplication.getPrefs().experimentalMetrics()
&& supportsMetrics(GENERIC_TRAINING_LOAD_ACUTE);
if (metricTrainingLoad) {
LOG.info("using experimental MetricSample for training load");
}
if (supportsTrainingLoad()) {
acuteLoadChart = rootView.findViewById(R.id.acute_load_chart);
acuteLoad = rootView.findViewById(R.id.acute_load);
@@ -123,6 +138,9 @@ public class LoadFragment extends AbstractChartFragment<LoadFragment.LoadsData>
}
public boolean supportsTrainingLoad() {
if (metricTrainingLoad) {
return true;
}
final GBDevice device = getChartsHost().getDevice();
return device.getDeviceCoordinator().supportsTrainingLoad(device);
}
@@ -295,13 +313,24 @@ public class LoadFragment extends AbstractChartFragment<LoadFragment.LoadsData>
load = workoutLoadSamples.stream().mapToInt(WorkoutLoadSample::getValue).sum();
}
if (supportsTrainingLoad()) {
List<? extends GenericTrainingLoadAcuteSample> workoutTrainingAcuteLoadSamples = getTrainingLoadAcuteSamples(db, device, startTs, endTs);
if (!workoutTrainingAcuteLoadSamples.isEmpty()) {
acuteLoad = workoutTrainingAcuteLoadSamples.get(workoutTrainingAcuteLoadSamples.size() - 1).getValue();
}
List<? extends GenericTrainingLoadChronicSample> workoutTrainingChronicLoadSamples = getTrainingLoadChronicSamples(db, device, startTs, endTs);
if (!workoutTrainingChronicLoadSamples.isEmpty()) {
chronicLoad = workoutTrainingChronicLoadSamples.get(workoutTrainingChronicLoadSamples.size() - 1).getValue();
if (metricTrainingLoad) {
List<? extends MetricSample> workoutTrainingAcuteLoadSamples = getMetricSamples(db, device, GENERIC_TRAINING_LOAD_ACUTE, startTs * 1000L, endTs * 1000L);
if (!workoutTrainingAcuteLoadSamples.isEmpty()) {
acuteLoad = (int) workoutTrainingAcuteLoadSamples.get(workoutTrainingAcuteLoadSamples.size() - 1).getMetricScore();
}
List<? extends MetricSample> workoutTrainingChronicLoadSamples = getMetricSamples(db, device, GENERIC_TRAINING_LOAD_CHRONIC, startTs * 1000L, endTs * 1000L);
if (!workoutTrainingChronicLoadSamples.isEmpty()) {
chronicLoad = (int) workoutTrainingChronicLoadSamples.get(workoutTrainingChronicLoadSamples.size() - 1).getMetricScore();
}
} else {
List<? extends GenericTrainingLoadAcuteSample> workoutTrainingAcuteLoadSamples = getTrainingLoadAcuteSamples(db, device, startTs, endTs);
if (!workoutTrainingAcuteLoadSamples.isEmpty()) {
acuteLoad = workoutTrainingAcuteLoadSamples.get(workoutTrainingAcuteLoadSamples.size() - 1).getValue();
}
List<? extends GenericTrainingLoadChronicSample> workoutTrainingChronicLoadSamples = getTrainingLoadChronicSamples(db, device, startTs, endTs);
if (!workoutTrainingChronicLoadSamples.isEmpty()) {
chronicLoad = workoutTrainingChronicLoadSamples.get(workoutTrainingChronicLoadSamples.size() - 1).getValue();
}
}
}
data.add(new LoadData((Calendar) day.clone(), load, acuteLoad, chronicLoad, i));
@@ -320,13 +349,27 @@ public class LoadFragment extends AbstractChartFragment<LoadFragment.LoadsData>
int latestChronicLoad = 0;
if (supportsTrainingLoad()) {
final Date dayEnd = DateTimeUtils.dayEnd(getEndDate());
GenericTrainingLoadAcuteSample latestAcuteLoadSample = getLatestTrainingLoadAcuteSample(db, device, dayEnd.getTime());
if (latestAcuteLoadSample != null) {
latestAcuteLoad = latestAcuteLoadSample.getValue();
}
GenericTrainingLoadChronicSample latestChronicLoadSample = getLatestTrainingLoadChronicSample(db, device, dayEnd.getTime());
if (latestChronicLoadSample != null) {
latestChronicLoad = latestChronicLoadSample.getValue();
if (metricTrainingLoad) {
MetricSample latestAcuteLoadSample = getLatestMetricSampleBefore(db, device, GENERIC_TRAINING_LOAD_ACUTE, dayEnd.getTime());
if (latestAcuteLoadSample != null) {
latestAcuteLoad = (int) latestAcuteLoadSample.getMetricScore();
}
MetricSample latestChronicLoadSample = getLatestMetricSampleBefore(db, device, GENERIC_TRAINING_LOAD_CHRONIC, dayEnd.getTime());
if (latestChronicLoadSample != null) {
latestChronicLoad = (int) latestChronicLoadSample.getMetricScore();
}
} else {
GenericTrainingLoadAcuteSample latestAcuteLoadSample = getLatestTrainingLoadAcuteSample(db, device, dayEnd.getTime());
if (latestAcuteLoadSample != null) {
latestAcuteLoad = latestAcuteLoadSample.getValue();
}
GenericTrainingLoadChronicSample latestChronicLoadSample = getLatestTrainingLoadChronicSample(db, device, dayEnd.getTime());
if (latestChronicLoadSample != null) {
latestChronicLoad = latestChronicLoadSample.getValue();
}
}
}
return new LoadsData(data, latestAcuteLoad, latestChronicLoad, thisWeekLoad, lastWeekLoad);
@@ -1,5 +1,5 @@
/* Copyright (C) 2019-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniel
Dakhno, José Rebelo, Petr Vaněk
/* Copyright (C) 2019-2026 Andreas Shimokawa, Carsten Pfeiffer, Daniel
Dakhno, José Rebelo, Petr Vaněk, Thomas Kuehne
This file is part of Gadgetbridge.
@@ -17,6 +17,10 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.model;
import static nodomain.freeyourgadget.gadgetbridge.devices.GenericMetricSampleProvider.getLatestMetricSample;
import static nodomain.freeyourgadget.gadgetbridge.devices.GenericMetricSampleProvider.supportsMetrics;
import static nodomain.freeyourgadget.gadgetbridge.model.MetricSample.Metric.GENERIC_RESTING_METABOLIC_RATE;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -159,8 +163,15 @@ public class DailyTotals implements Serializable {
day.set(Calendar.MINUTE, 0);
day.set(Calendar.SECOND, 0);
day.add(Calendar.HOUR, 0);
RestingMetabolicRateSample metabolicRate = getRestingMetabolicRate(db, day, device);
if (metabolicRate == null) {
final Integer restingMetabolicRate;
if (GBApplication.getPrefs().experimentalMetrics() && supportsMetrics(device, GENERIC_RESTING_METABOLIC_RATE)) {
MetricSample sample = getLatestMetricSample(db, device, GENERIC_RESTING_METABOLIC_RATE, day);
restingMetabolicRate = (sample == null) ? null : (int) sample.getMetricScore();
} else {
RestingMetabolicRateSample sample = getRestingMetabolicRate(db, day, device);
restingMetabolicRate = (sample == null) ? null : sample.getRestingMetabolicRate();
}
if (restingMetabolicRate == null) {
// should never happen
return 0;
}
@@ -170,7 +181,7 @@ public class DailyTotals implements Serializable {
if (sameDay) {
passedDayProportion = (double) (calendar.getTimeInMillis() - day.getTimeInMillis()) / (24L * 60 * 60 * 1000);
}
return (int) (metabolicRate.getRestingMetabolicRate() * passedDayProportion);
return (int) (restingMetabolicRate * passedDayProportion);
}
public static List<? extends ActivitySample> getSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
@@ -238,4 +238,8 @@ public class GBPrefs extends Prefs {
public boolean experimentalSettings() {
return getBoolean("experimental_settings", false);
}
public boolean experimentalMetrics() {
return getBoolean("experimental_metrics", false);
}
}
+2
View File
@@ -773,6 +773,8 @@
<string name="pref_cache_weather">Cache weather information</string>
<string name="pref_experimental_settings_title">Experimental settings</string>
<string name="pref_experimental_settings_summary">Experimental settings might be unstable or not work as expected. Proceed at your own risk.</string>
<string name="pref_experimental_metrics_title">Generic Metrics</string>
<string name="pref_experimental_metrics_summary">Enable experimental UI logic for generic metrics. Currently only relevant for training load and calories recorded by Garmin devices.</string>
<string name="pref_cache_weather_summary">Weather information will be cached across application restarts.</string>
<string name="pref_write_logfiles_not_available">File logging initialization failed, writing log files is currently not available. Restart the application to attempt to initialize the log files again.</string>
<string name="initializing">Initializing</string>
+8
View File
@@ -540,6 +540,14 @@
android:summary="@string/pref_experimental_settings_summary"
android:title="@string/pref_experimental_settings_title"
app:iconSpaceReserved="false" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="experimental_metrics"
android:layout="@layout/preference_checkbox"
android:summary="@string/pref_experimental_metrics_summary"
android:title="@string/pref_experimental_metrics_title"
app:iconSpaceReserved="false" />
</PreferenceScreen>
<PreferenceScreen