mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
Merge pull request 'MetricsSamples: add experimental UI visualisation paths for TrainingAcuteLoad, TrainingChronicLoad and RestingMetabolicRate' (#6076)
Reviewed-on: https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/6076
This commit is contained in:
+10
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+29
-5
@@ -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
|
||||
|
||||
+28
-2
@@ -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);
|
||||
|
||||
+58
-15
@@ -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);
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/* Copyright (C) 2026 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.database.schema;
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.GenericMetricSampleDao;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.MetricSample;
|
||||
|
||||
public class GadgetbridgeUpdate_131 implements DBUpdateScript {
|
||||
|
||||
private static void fixTimeStampOfGenericMetricSample(SQLiteDatabase db) {
|
||||
// GenericMetricSamples were originally stored with epoc seconds instead
|
||||
// of the usual epoc milli seconds
|
||||
// epoc 31536000000 ms = epoc 31536000 s = year 1971
|
||||
String statement = String.format("UPDATE %1$s SET %2$s = %2$s * 1000 WHERE %2$s < 31536000000",
|
||||
GenericMetricSampleDao.TABLENAME,
|
||||
GenericMetricSampleDao.Properties.Timestamp.columnName);
|
||||
db.execSQL(statement);
|
||||
}
|
||||
|
||||
private static void copyTrainingLoadAcute(SQLiteDatabase db) {
|
||||
// one-off copy existing data so that vizualizations
|
||||
// and migration can be tested before removing old code paths
|
||||
final String sql;
|
||||
sql = "INSERT OR IGNORE INTO GENERIC_METRIC_SAMPLE(TIMESTAMP, DEVICE_ID, USER_ID, METRIC_TYPE ,METRIC_SCORE, METRIC_EXTRA) "
|
||||
+ "SELECT TIMESTAMP, DEVICE_ID, USER_ID, " + MetricSample.Metric.GENERIC_TRAINING_LOAD_ACUTE.dbId + ", VALUE, NULL "
|
||||
+ "FROM GENERIC_TRAINING_LOAD_ACUTE_SAMPLE";
|
||||
db.execSQL(sql);
|
||||
}
|
||||
|
||||
private static void copyTrainingLoadChronic(SQLiteDatabase db) {
|
||||
// one-off copy existing data so that vizualizations
|
||||
// and migration can be tested before removing old code paths
|
||||
final String sql;
|
||||
sql = "INSERT OR IGNORE INTO GENERIC_METRIC_SAMPLE(TIMESTAMP, DEVICE_ID, USER_ID, METRIC_TYPE ,METRIC_SCORE, METRIC_EXTRA) "
|
||||
+ "SELECT TIMESTAMP, DEVICE_ID, USER_ID, " + MetricSample.Metric.GENERIC_TRAINING_LOAD_CHRONIC.dbId + ", VALUE, NULL "
|
||||
+ "FROM GENERIC_TRAINING_LOAD_CHRONIC_SAMPLE";
|
||||
db.execSQL(sql);
|
||||
}
|
||||
|
||||
private static void copyRestingMetabolicRate(SQLiteDatabase db) {
|
||||
// one-off copy existing data so that vizualizations
|
||||
// and migration can be tested before removing old code paths
|
||||
final String sql;
|
||||
sql = "INSERT OR IGNORE INTO GENERIC_METRIC_SAMPLE(TIMESTAMP, DEVICE_ID, USER_ID, METRIC_TYPE ,METRIC_SCORE, METRIC_EXTRA) "
|
||||
+ "SELECT TIMESTAMP, DEVICE_ID, USER_ID, " + MetricSample.Metric.GENERIC_RESTING_METABOLIC_RATE.dbId + ", RESTING_METABOLIC_RATE, NULL "
|
||||
+ "FROM GARMIN_RESTING_METABOLIC_RATE_SAMPLE";
|
||||
db.execSQL(sql);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void upgradeSchema(final SQLiteDatabase db) {
|
||||
fixTimeStampOfGenericMetricSample(db);
|
||||
copyTrainingLoadAcute(db);
|
||||
copyTrainingLoadChronic(db);
|
||||
copyRestingMetabolicRate(db);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void downgradeSchema(final SQLiteDatabase db) {
|
||||
}
|
||||
}
|
||||
+12
@@ -94,6 +94,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.GpxActivityTrackProvider;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.HeartRateSample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.HrvSummarySample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.HrvValueSample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.MetricSample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.PaiSample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.RespiratoryRateSample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.RestingMetabolicRateSample;
|
||||
@@ -1208,4 +1209,15 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator {
|
||||
public boolean supportsConnectionPriority() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GenericMetricSampleProvider getMetricsSampleProvider(@NonNull final GBDevice device, @NonNull final DaoSession session) {
|
||||
return new GenericMetricSampleProvider(device, session);
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public Set<MetricSample.Metric> supportsMetrics(@NonNull GBDevice device) {
|
||||
return GenericMetricSampleProvider.getSupportedMetrics(device);
|
||||
}
|
||||
}
|
||||
|
||||
+18
-1
@@ -64,6 +64,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.HeartRateSample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.HrvSummarySample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.HrvValueSample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.MetricSample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.PaiSample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.RespiratoryRateSample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.RestingMetabolicRateSample;
|
||||
@@ -418,14 +419,18 @@ public interface DeviceCoordinator {
|
||||
|
||||
/**
|
||||
* Returns the sample provider for training acute load values, for the device being supported.
|
||||
* @deprecated see {@link #getMetricsSampleProvider(GBDevice, DaoSession)} and {@link MetricSample.Metric#GENERIC_TRAINING_LOAD_ACUTE}
|
||||
*/
|
||||
@Deprecated
|
||||
@Nullable
|
||||
TimeSampleProvider<? extends GenericTrainingLoadAcuteSample> getTrainingAcuteLoadSampleProvider(@NonNull final GBDevice device, @NonNull final DaoSession session);
|
||||
|
||||
|
||||
/**
|
||||
* Returns the sample provider for training chronic load values, for the device being supported.
|
||||
* @deprecated see {@link #getMetricsSampleProvider(GBDevice, DaoSession)} and {@link MetricSample.Metric#GENERIC_TRAINING_LOAD_CHRONIC}
|
||||
*/
|
||||
@Deprecated
|
||||
@Nullable
|
||||
TimeSampleProvider<? extends GenericTrainingLoadChronicSample> getTrainingChronicLoadSampleProvider(@NonNull final GBDevice device, @NonNull final DaoSession session);
|
||||
|
||||
@@ -504,6 +509,8 @@ public interface DeviceCoordinator {
|
||||
@Nullable
|
||||
TimeSampleProvider<? extends WeightSample> getWeightSampleProvider(@NonNull final GBDevice device, @NonNull final DaoSession session);
|
||||
|
||||
/// @deprecated see {@link #getMetricsSampleProvider(GBDevice, DaoSession)} and {@link MetricSample.Metric#GENERIC_RESTING_METABOLIC_RATE}
|
||||
@Deprecated
|
||||
@Nullable
|
||||
TimeSampleProvider<? extends RestingMetabolicRateSample> getRestingMetabolicRateProvider(@NonNull final GBDevice device, @NonNull final DaoSession session);
|
||||
|
||||
@@ -876,7 +883,7 @@ public interface DeviceCoordinator {
|
||||
/**
|
||||
* Indicates which device specific settings the device supports (not per device type or family, but unique per device).
|
||||
*
|
||||
* @deprecated use getDeviceSpecificSettings
|
||||
* @deprecated use {@link #getDeviceSpecificSettings(GBDevice)}
|
||||
*/
|
||||
@Deprecated
|
||||
@Nullable
|
||||
@@ -1002,4 +1009,14 @@ public interface DeviceCoordinator {
|
||||
* @return
|
||||
*/
|
||||
boolean supportsConnectionPriority();
|
||||
|
||||
/// @see MetricSample
|
||||
/// @see #getMetricsSampleProvider(GBDevice, DaoSession)
|
||||
@NonNull
|
||||
Set<MetricSample.Metric> supportsMetrics(@NonNull GBDevice device);
|
||||
|
||||
/// @see MetricSample
|
||||
/// @see #supportsMetrics(GBDevice)
|
||||
@Nullable
|
||||
GenericMetricSampleProvider getMetricsSampleProvider(@NonNull GBDevice device, @NonNull DaoSession session);
|
||||
}
|
||||
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
/* Copyright (C) 2026 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.devices;
|
||||
|
||||
import android.database.Cursor;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Calendar;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import de.greenrobot.dao.AbstractDao;
|
||||
import de.greenrobot.dao.Property;
|
||||
import de.greenrobot.dao.query.QueryBuilder;
|
||||
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
|
||||
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
|
||||
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.DeviceDao;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.GenericMetricSample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.GenericMetricSampleDao;
|
||||
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.MetricSample;
|
||||
|
||||
public class GenericMetricSampleProvider extends AbstractTimeSampleProvider<GenericMetricSample> {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(GenericMetricSampleProvider.class);
|
||||
|
||||
public GenericMetricSampleProvider(@NonNull final GBDevice device, @NonNull final DaoSession session) {
|
||||
super(device, session);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static List<? extends MetricSample> getMetricSamples(final @NonNull DBHandler db, final @NonNull GBDevice device, @NonNull MetricSample.Metric metric, long tsFromMillis, long tsToMillis) {
|
||||
final DeviceCoordinator coordinator = device.getDeviceCoordinator();
|
||||
final GenericMetricSampleProvider sampleProvider = coordinator.getMetricsSampleProvider(device, db.getDaoSession());
|
||||
if (sampleProvider == null) {
|
||||
LOG.warn("Device {} does not implement MetricSample", device);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
final List<? extends MetricSample> samples = sampleProvider.getAllSamples(metric, tsFromMillis, tsToMillis);
|
||||
return samples;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static MetricSample getLatestMetricSampleBefore(@NonNull final DBHandler db, @NonNull final GBDevice device, @NonNull MetricSample.Metric metric, long tsToMillis) {
|
||||
final DeviceCoordinator coordinator = device.getDeviceCoordinator();
|
||||
final GenericMetricSampleProvider sampleProvider = coordinator.getMetricsSampleProvider(device, db.getDaoSession());
|
||||
if (sampleProvider == null) {
|
||||
LOG.warn("Device {} does not implement MetricSample", device);
|
||||
return null;
|
||||
}
|
||||
final MetricSample sample = sampleProvider.getLatestSampleBefore(metric, tsToMillis);
|
||||
return sample;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static MetricSample getLatestMetricSample(@NonNull final DBHandler db, @NonNull final GBDevice device, @NonNull MetricSample.Metric metric, @NotNull Calendar day) {
|
||||
long tsStart = day.getTimeInMillis();
|
||||
long tsEnd = tsStart + 24 * 60 * 60 * 1000L;
|
||||
final MetricSample sample = getLatestMetricSample(db, device, metric, tsStart, tsEnd);
|
||||
return sample;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static MetricSample getLatestMetricSample(@NonNull final DBHandler db, @NonNull final GBDevice device, @NonNull MetricSample.Metric metric, long tsFromMillis, long tsToMillis) {
|
||||
final DeviceCoordinator coordinator = device.getDeviceCoordinator();
|
||||
final GenericMetricSampleProvider sampleProvider = coordinator.getMetricsSampleProvider(device, db.getDaoSession());
|
||||
if (sampleProvider == null) {
|
||||
LOG.warn("Device {} does not implement MetricSample", device);
|
||||
return null;
|
||||
}
|
||||
final MetricSample sample = sampleProvider.getLatestSample(metric, tsFromMillis, tsToMillis);
|
||||
return sample;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static MetricSample getLatestMetricSample(@NonNull final DBHandler db, @NonNull final GBDevice device, @NonNull MetricSample.Metric metric) {
|
||||
final DeviceCoordinator coordinator = device.getDeviceCoordinator();
|
||||
final GenericMetricSampleProvider sampleProvider = coordinator.getMetricsSampleProvider(device, db.getDaoSession());
|
||||
if (sampleProvider == null) {
|
||||
LOG.warn("Device {} does not implement MetricSample", device);
|
||||
return null;
|
||||
}
|
||||
final MetricSample sample = sampleProvider.getLatestSample(metric);
|
||||
return sample;
|
||||
}
|
||||
|
||||
public static boolean supportsMetrics(@NonNull GBDevice device, @NonNull MetricSample.Metric metric) {
|
||||
// check the database to see what if this metric has actually been recorded.
|
||||
HashSet<MetricSample.Metric> recorded = new HashSet<>();
|
||||
try (DBHandler handler = GBApplication.acquireDbReadOnly()) {
|
||||
SQLiteDatabase db = handler.getDatabase();
|
||||
String sql = String.format(
|
||||
"SELECT %1$s.%2$s FROM %1$s INNER JOIN %4$s ON %1$s.%3$s = %4$s.%5$s WHERE %4$s.%6$s = ? AND %1$s.%2$s = ? LIMIT 1",
|
||||
GenericMetricSampleDao.TABLENAME,
|
||||
GenericMetricSampleDao.Properties.MetricType.columnName,
|
||||
GenericMetricSampleDao.Properties.DeviceId.columnName,
|
||||
DeviceDao.TABLENAME,
|
||||
DeviceDao.Properties.Id.columnName,
|
||||
DeviceDao.Properties.Identifier.columnName
|
||||
);
|
||||
|
||||
try (final Cursor c = db.rawQuery(sql, new String[]{device.getAddress(), Integer.toString(metric.dbId)})) {
|
||||
while (c.moveToNext()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warn("failed to identify recorded metrics ", e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static Set<MetricSample.Metric> getSupportedMetrics(@NonNull GBDevice device) {
|
||||
// check the database to see what metrics have actually been recorded
|
||||
// instead of guessing firmware version and usage dependent capabilities
|
||||
HashSet<MetricSample.Metric> recorded = new HashSet<>();
|
||||
try (DBHandler handler = GBApplication.acquireDbReadOnly()) {
|
||||
SQLiteDatabase db = handler.getDatabase();
|
||||
String sql = String.format(
|
||||
"SELECT DISTINCT %1$s.%2$s FROM %1$s WHERE %1$s.%3$s = (SELECT %4$s.%5$s FROM %4$s WHERE %4$s.%6$s = ? LIMIT 1) ORDER BY %1$s.%2$s",
|
||||
GenericMetricSampleDao.TABLENAME,
|
||||
GenericMetricSampleDao.Properties.MetricType.columnName,
|
||||
GenericMetricSampleDao.Properties.DeviceId.columnName,
|
||||
DeviceDao.TABLENAME,
|
||||
DeviceDao.Properties.Id.columnName,
|
||||
DeviceDao.Properties.Identifier.columnName
|
||||
);
|
||||
|
||||
try (final Cursor c = db.rawQuery(sql, new String[]{device.getAddress()})) {
|
||||
while (c.moveToNext()) {
|
||||
final int dbId = c.getInt(0);
|
||||
MetricSample.Metric metric = MetricSample.Metric.fromDbId(dbId);
|
||||
if (metric == null) {
|
||||
LOG.warn("unknown MetricSample.Metric id: {}", dbId);
|
||||
} else {
|
||||
recorded.add(metric);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warn("failed to identify recorded metrics ", e);
|
||||
}
|
||||
return recorded;
|
||||
}
|
||||
|
||||
public static Set<MetricSample.Metric> supportsMetrics(@NonNull GBDevice device) {
|
||||
DeviceCoordinator coordinator = device.getDeviceCoordinator();
|
||||
Set<MetricSample.Metric> supported = coordinator.supportsMetrics(device);
|
||||
return supported;
|
||||
}
|
||||
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public AbstractDao<GenericMetricSample, ?> getSampleDao() {
|
||||
return getSession().getGenericMetricSampleDao();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
protected Property getTimestampSampleProperty() {
|
||||
return GenericMetricSampleDao.Properties.Timestamp;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
protected Property getDeviceIdentifierSampleProperty() {
|
||||
return GenericMetricSampleDao.Properties.DeviceId;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public GenericMetricSample createSample() {
|
||||
return new GenericMetricSample();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public List<GenericMetricSample> getAllSamples(@NonNull final MetricSample.Metric metric, final long timestampFrom, final long timestampTo) {
|
||||
final Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
|
||||
if (dbDevice == null) {
|
||||
// no device, no samples
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
final AbstractDao<GenericMetricSample, ?> dao = getSampleDao();
|
||||
final QueryBuilder<GenericMetricSample> qb = dao.queryBuilder();
|
||||
final List<GenericMetricSample> samples = qb
|
||||
.where(
|
||||
GenericMetricSampleDao.Properties.DeviceId.eq(dbDevice.getId()),
|
||||
GenericMetricSampleDao.Properties.Timestamp.between(timestampFrom, timestampTo),
|
||||
GenericMetricSampleDao.Properties.MetricType.eq(metric.dbId)
|
||||
)
|
||||
.orderAsc(GenericMetricSampleDao.Properties.Timestamp)
|
||||
.list();
|
||||
return samples;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public MetricSample getLatestSampleBefore(@NonNull final MetricSample.Metric metric, final long until) {
|
||||
final Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
|
||||
if (dbDevice == null) {
|
||||
// no device, no sample
|
||||
return null;
|
||||
}
|
||||
|
||||
final AbstractDao<GenericMetricSample, ?> dao = getSampleDao();
|
||||
final QueryBuilder<GenericMetricSample> qb = dao.queryBuilder();
|
||||
final List<GenericMetricSample> samples = qb
|
||||
.where(
|
||||
GenericMetricSampleDao.Properties.DeviceId.eq(dbDevice.getId()),
|
||||
GenericMetricSampleDao.Properties.Timestamp.le(until),
|
||||
GenericMetricSampleDao.Properties.MetricType.eq(metric.dbId)
|
||||
)
|
||||
.orderDesc(GenericMetricSampleDao.Properties.Timestamp)
|
||||
.limit(1)
|
||||
.list();
|
||||
if (samples.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
final GenericMetricSample sample = samples.getFirst();
|
||||
return sample;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public GenericMetricSample getLatestSample(@NonNull final MetricSample.Metric metric) {
|
||||
final Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
|
||||
if (dbDevice == null) {
|
||||
// no device, no sample
|
||||
return null;
|
||||
}
|
||||
|
||||
final AbstractDao<GenericMetricSample, ?> dao = getSampleDao();
|
||||
final QueryBuilder<GenericMetricSample> qb = dao.queryBuilder();
|
||||
final List<GenericMetricSample> samples = qb
|
||||
.where(
|
||||
GenericMetricSampleDao.Properties.DeviceId.eq(dbDevice.getId()),
|
||||
GenericMetricSampleDao.Properties.MetricType.eq(metric.dbId)
|
||||
)
|
||||
.orderDesc(GenericMetricSampleDao.Properties.Timestamp)
|
||||
.limit(1)
|
||||
.list();
|
||||
if (samples.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
final GenericMetricSample sample = samples.getFirst();
|
||||
return sample;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public GenericMetricSample getLatestSample(@NonNull final MetricSample.Metric metric, long tsFromMillis, long tsToMillis) {
|
||||
final Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
|
||||
if (dbDevice == null) {
|
||||
// no device, no sample
|
||||
return null;
|
||||
}
|
||||
|
||||
final AbstractDao<GenericMetricSample, ?> dao = getSampleDao();
|
||||
final QueryBuilder<GenericMetricSample> qb = dao.queryBuilder();
|
||||
final List<GenericMetricSample> samples = qb
|
||||
.where(
|
||||
GenericMetricSampleDao.Properties.DeviceId.eq(dbDevice.getId()),
|
||||
GenericMetricSampleDao.Properties.MetricType.eq(metric.dbId),
|
||||
GenericMetricSampleDao.Properties.Timestamp.between(tsFromMillis, tsToMillis)
|
||||
)
|
||||
.orderDesc(GenericMetricSampleDao.Properties.Timestamp)
|
||||
.limit(1)
|
||||
.list();
|
||||
if (samples.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
final GenericMetricSample sample = samples.getFirst();
|
||||
return sample;
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -43,11 +43,13 @@ import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.Activity
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryTableBuilder;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryValue;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.GenericMetricSample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.MetricSample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.workout.Workout;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.workout.WorkoutChart;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.AntGadget;
|
||||
@@ -116,6 +118,7 @@ public class GarminWorkoutParser implements ActivitySummaryParser {
|
||||
private Number ebikeBatteryEnd = null;
|
||||
@Nullable
|
||||
private FitWorkout workout = null;
|
||||
private final List<GenericMetricSample> genericMetricSamples = new ArrayList<>();
|
||||
|
||||
public GarminWorkoutParser(final Context context) {
|
||||
this.context = context;
|
||||
@@ -126,6 +129,11 @@ public class GarminWorkoutParser implements ActivitySummaryParser {
|
||||
return (session != null) ? session.getStartTime() : null;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public List<GenericMetricSample> getGenericMetricSamples(){
|
||||
return genericMetricSamples;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BaseActivitySummary parseBinaryData(BaseActivitySummary summary, boolean forDetails) {
|
||||
// FIXME Do not use this
|
||||
@@ -740,7 +748,14 @@ public class GarminWorkoutParser implements ActivitySummaryParser {
|
||||
summaryData.add(TRAINING_EFFECT_AEROBIC, physiologicalMetrics.getAerobicEffect(), UNIT_NONE, true);
|
||||
}
|
||||
if (physiologicalMetrics.getMetMax() != null) {
|
||||
summaryData.add(MAXIMUM_OXYGEN_UPTAKE, physiologicalMetrics.getMetMax().floatValue() * 3.5f, UNIT_ML_KG_MIN);
|
||||
float vo2Max = physiologicalMetrics.getMetMax().floatValue() * 3.5f;
|
||||
if (vo2Max > 0.0f) {
|
||||
summaryData.add(MAXIMUM_OXYGEN_UPTAKE, vo2Max, UNIT_ML_KG_MIN);
|
||||
final GenericMetricSample sample = new GenericMetricSample();
|
||||
sample.setTimestamp(physiologicalMetrics.computedTimestamp * 1000L);
|
||||
sample.setMetric(MetricSample.Metric.GENERIC_MAXIMUM_OXYGEN_UPTAKE, vo2Max);
|
||||
genericMetricSamples.add(sample);
|
||||
}
|
||||
}
|
||||
if (physiologicalMetrics.getRecoveryTime() != null) {
|
||||
summaryData.add(RECOVERY_TIME, physiologicalMetrics.getRecoveryTime() * 60, UNIT_SECONDS);
|
||||
|
||||
+1
@@ -215,6 +215,7 @@ public class ActivitySummaryEntries {
|
||||
public static final String UNIT_CM = "cm";
|
||||
public static final String UNIT_UNIX_EPOCH_SECONDS = "unix_epoch_seconds";
|
||||
public static final String UNIT_KCAL = "calories_unit";
|
||||
public static final String UNIT_KCAL_PER_DAY = "unit_kcal_per_day";
|
||||
public static final String UNIT_ML = "ml";
|
||||
public static final String UNIT_LAPS = "laps_unit";
|
||||
public static final String UNIT_KILOMETERS = "km";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.model;
|
||||
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_KCAL_PER_DAY;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_ML_KG_MIN;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_NONE;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_WATT;
|
||||
|
||||
import androidx.annotation.IntRange;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -24,8 +29,14 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitFunctionalMetrics;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitHillScore;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitMaxMetData;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitMonitoringInfo;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitPhysiologicalMetrics;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitTrainingLoad;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitTrainingReadiness;
|
||||
|
||||
/**
|
||||
* This is for infrequent statistics samples - for example one record per day and metric.
|
||||
*/
|
||||
public interface MetricSample extends TimeSample {
|
||||
default Metric getMetric() {
|
||||
int dbId = getMetricType();
|
||||
@@ -36,21 +47,25 @@ public interface MetricSample extends TimeSample {
|
||||
setMetricType(type.dbId);
|
||||
}
|
||||
|
||||
default void setMetric(@NonNull Metric type, @Nullable Double value, @Nullable Long extra) {
|
||||
default void setMetric(@NonNull Metric type, double score) {
|
||||
setMetric(type, score, null);
|
||||
}
|
||||
|
||||
default void setMetric(@NonNull Metric type, double score, @Nullable Long extra) {
|
||||
setMetricType(type.dbId);
|
||||
setMetricScore(value);
|
||||
setMetricScore(score);
|
||||
setMetricExtra(extra);
|
||||
}
|
||||
|
||||
@IntRange(from = 0, to = 12)
|
||||
int getMetricType();
|
||||
|
||||
/// use {@link #setMetric(Metric)} or {@link #setMetric(Metric, Double, Long)} instead
|
||||
void setMetricType(@IntRange(from = 0, to = 8) int type);
|
||||
void setMetricType(@IntRange(from = 1, to = 12) int type);
|
||||
|
||||
@Nullable
|
||||
Double getMetricScore();
|
||||
double getMetricScore();
|
||||
|
||||
void setMetricScore(@Nullable Double value);
|
||||
void setMetricScore(double score);
|
||||
|
||||
@Nullable
|
||||
Long getMetricExtra();
|
||||
@@ -58,40 +73,57 @@ public interface MetricSample extends TimeSample {
|
||||
void setMetricExtra(@Nullable Long extra);
|
||||
|
||||
enum Metric {
|
||||
UNKNOWN(0),
|
||||
UNKNOWN(0, UNIT_NONE),
|
||||
/// @see FitEnduranceScore#getEnduranceScore()
|
||||
/// @see FitEnduranceScore#getLevel()
|
||||
GARMIN_ENDURANCE_SCORE(1),
|
||||
GARMIN_ENDURANCE_SCORE(1, UNIT_NONE),
|
||||
/// @see FitFunctionalMetrics#getFunctionalThresholdPower()
|
||||
/// @see FitFunctionalMetrics#getCyclingLactaceThresholdHr()
|
||||
GARMIN_FUNCTIONAL_THRESHOLD_POWER(2),
|
||||
GARMIN_FUNCTIONAL_THRESHOLD_POWER(2, UNIT_WATT),
|
||||
/// @see FitHillScore#getHillEndurance()
|
||||
/// @see FitHillScore#getLevel()
|
||||
GARMIN_HILL_ENDURANCE(3),
|
||||
GARMIN_HILL_ENDURANCE(3, UNIT_NONE),
|
||||
/// @see FitHillScore#getHillScore()
|
||||
/// @see FitHillScore#getLevel()
|
||||
GARMIN_HILL_SCORE(4),
|
||||
GARMIN_HILL_SCORE(4, UNIT_NONE),
|
||||
/// @see FitHillScore#getHillStrength()
|
||||
/// @see FitHillScore#getLevel()
|
||||
GARMIN_HILL_STRENGTH(5),
|
||||
GARMIN_HILL_STRENGTH(5, UNIT_NONE),
|
||||
/// This is a metabolic equivalent (MET) version of {@link #GENERIC_MAXIMUM_OXYGEN_UPTAKE}.
|
||||
/// Estimated using 24/7 monitoring instead of high resolution activity recordings.
|
||||
///
|
||||
/// @see FitMaxMetData#getVo2Max()
|
||||
/// @see FitMaxMetData#getMaxMetCategory()
|
||||
GARMIN_MET_MAX_VO2(6),
|
||||
GARMIN_MET_MAX_VO2(6, UNIT_ML_KG_MIN),
|
||||
/// @see FitFunctionalMetrics#getRunningLactateThresholdPower()
|
||||
/// @see FitFunctionalMetrics#getRunningLactateThresholdHr()
|
||||
GARMIN_RUNNING_LACTATE_THRESHOLD_POWER(7),
|
||||
GARMIN_RUNNING_LACTATE_THRESHOLD_POWER(7, UNIT_WATT),
|
||||
/// @see FitTrainingReadiness#getTrainingReadiness()
|
||||
/// @see FitTrainingReadiness#getLevel()
|
||||
GARMIN_TRAINING_READINESS(8);
|
||||
GARMIN_TRAINING_READINESS(8, UNIT_NONE),
|
||||
/// @see FitTrainingLoad#getTrainingLoadAcute
|
||||
GENERIC_TRAINING_LOAD_ACUTE(9, UNIT_NONE),
|
||||
/// @see FitTrainingLoad#getTrainingLoadChronic()
|
||||
GENERIC_TRAINING_LOAD_CHRONIC(10, UNIT_NONE),
|
||||
/// @see FitMonitoringInfo#getRestingMetabolicRate()
|
||||
GENERIC_RESTING_METABOLIC_RATE(11, UNIT_KCAL_PER_DAY),
|
||||
/// @see FitPhysiologicalMetrics#getMetMax()
|
||||
GENERIC_MAXIMUM_OXYGEN_UPTAKE(12, UNIT_ML_KG_MIN),
|
||||
;
|
||||
|
||||
final int dbId;
|
||||
final public int dbId;
|
||||
|
||||
Metric(int dbId) {
|
||||
/// @see nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries
|
||||
@NonNull
|
||||
final public String uomKey;
|
||||
|
||||
Metric(int dbId, @NonNull String uomKey) {
|
||||
this.dbId = dbId;
|
||||
this.uomKey = uomKey;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
static Metric fromDbId(int dbId) {
|
||||
public static Metric fromDbId(int dbId) {
|
||||
for (Metric metric : values()) {
|
||||
if (metric.dbId == dbId) {
|
||||
return metric;
|
||||
|
||||
+42
-16
@@ -16,6 +16,8 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
|
||||
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.MAXIMUM_OXYGEN_UPTAKE;
|
||||
|
||||
import android.content.Context;
|
||||
import android.widget.Toast;
|
||||
|
||||
@@ -90,6 +92,7 @@ import nodomain.freeyourgadget.gadgetbridge.export.AutoGpxExporter;
|
||||
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.FitActivityTrackProvider;
|
||||
@@ -432,7 +435,7 @@ public class FitImporter {
|
||||
final double endurance = (rawEndurance == null) ? Double.NaN : rawEndurance.doubleValue();
|
||||
if (endurance > 0.0) {
|
||||
final GenericMetricSample sample = new GenericMetricSample();
|
||||
sample.setTimestamp(ts);
|
||||
sample.setTimestamp(ts * 1000L);
|
||||
sample.setMetric(MetricSample.Metric.GARMIN_HILL_ENDURANCE, endurance, level);
|
||||
genericMetricSamples.add(sample);
|
||||
}
|
||||
@@ -441,7 +444,7 @@ public class FitImporter {
|
||||
final double score = (rawScore == null) ? Double.NaN : rawScore.doubleValue();
|
||||
if (score > 0.0) {
|
||||
final GenericMetricSample sample = new GenericMetricSample();
|
||||
sample.setTimestamp(ts);
|
||||
sample.setTimestamp(ts * 1000L);
|
||||
sample.setMetric(MetricSample.Metric.GARMIN_HILL_SCORE, score, level);
|
||||
genericMetricSamples.add(sample);
|
||||
}
|
||||
@@ -450,7 +453,7 @@ public class FitImporter {
|
||||
final double strength = (rawStrength == null) ? Double.NaN : rawStrength.doubleValue();
|
||||
if (strength > 0.0) {
|
||||
final GenericMetricSample sample = new GenericMetricSample();
|
||||
sample.setTimestamp(ts);
|
||||
sample.setTimestamp(ts * 1000L);
|
||||
sample.setMetric(MetricSample.Metric.GARMIN_HILL_STRENGTH, strength, level);
|
||||
genericMetricSamples.add(sample);
|
||||
}
|
||||
@@ -463,7 +466,7 @@ public class FitImporter {
|
||||
|
||||
if ((level != null && level > 0) || (readiness != null && readiness > 0)) {
|
||||
final GenericMetricSample sample = new GenericMetricSample();
|
||||
sample.setTimestamp(ts);
|
||||
sample.setTimestamp(ts * 1000L);
|
||||
sample.setMetric(MetricSample.Metric.GARMIN_TRAINING_READINESS, readiness, level);
|
||||
genericMetricSamples.add(sample);
|
||||
}
|
||||
@@ -476,7 +479,7 @@ public class FitImporter {
|
||||
|
||||
if ((level != null && level > 0) || (score != null && score > 0)) {
|
||||
final GenericMetricSample sample = new GenericMetricSample();
|
||||
sample.setTimestamp(ts);
|
||||
sample.setTimestamp(ts * 1000L);
|
||||
sample.setMetric(MetricSample.Metric.GARMIN_ENDURANCE_SCORE, score, level);
|
||||
genericMetricSamples.add(sample);
|
||||
}
|
||||
@@ -488,7 +491,7 @@ public class FitImporter {
|
||||
final Long hr = (rawHr == null) ? null : rawHr.longValue();
|
||||
|
||||
final GenericMetricSample sample = new GenericMetricSample();
|
||||
sample.setTimestamp(ts);
|
||||
sample.setTimestamp(ts * 1000L);
|
||||
sample.setMetric(MetricSample.Metric.GARMIN_FUNCTIONAL_THRESHOLD_POWER, ftp, hr);
|
||||
genericMetricSamples.add(sample);
|
||||
}
|
||||
@@ -500,7 +503,7 @@ public class FitImporter {
|
||||
final Long hr = (rawHr == null) ? null : rawHr.longValue();
|
||||
|
||||
final GenericMetricSample sample = new GenericMetricSample();
|
||||
sample.setTimestamp(ts);
|
||||
sample.setTimestamp(ts * 1000L);
|
||||
sample.setMetric(MetricSample.Metric.GARMIN_RUNNING_LACTATE_THRESHOLD_POWER, ltp, hr);
|
||||
genericMetricSamples.add(sample);
|
||||
}
|
||||
@@ -513,7 +516,7 @@ public class FitImporter {
|
||||
|
||||
if ((vo2Max != null && vo2Max > 0) || (maxMetCategory != null && maxMetCategory > 0)) {
|
||||
final GenericMetricSample sample = new GenericMetricSample();
|
||||
sample.setTimestamp(ts);
|
||||
sample.setTimestamp(ts * 1000L);
|
||||
sample.setMetric(MetricSample.Metric.GARMIN_MET_MAX_VO2, vo2Max, maxMetCategory);
|
||||
genericMetricSamples.add(sample);
|
||||
}
|
||||
@@ -619,7 +622,7 @@ public class FitImporter {
|
||||
final long deviceId = DBHelper.getDevice(gbDevice, session).getId();
|
||||
final long userId = DBHelper.getUser(session).getId();
|
||||
persistAbstractSamples(batterySamples, new BatteryLevelProvider(gbDevice, session));
|
||||
persistGenericMetrics(session, deviceId, userId);
|
||||
persistMetricSamples(session);
|
||||
} catch (final Exception e) {
|
||||
GB.toast(context, "Error saving generic samples", Toast.LENGTH_LONG, GB.ERROR, e);
|
||||
}
|
||||
@@ -630,14 +633,35 @@ public class FitImporter {
|
||||
}
|
||||
}
|
||||
|
||||
private void persistGenericMetrics(final DaoSession session, final long deviceId, final long userId) {
|
||||
if (!genericMetricSamples.isEmpty()) {
|
||||
genericMetricSamples.forEach(sample -> {
|
||||
sample.setUserId(userId);
|
||||
sample.setDeviceId(deviceId);
|
||||
});
|
||||
session.getGenericMetricSampleDao().insertOrReplaceInTx(genericMetricSamples);
|
||||
private void persistMetricSamples(@NonNull final DaoSession session) {
|
||||
// During the transition phase some samples are persisted twice:
|
||||
// 1) to the old individual tables (see importFile)
|
||||
// 2) to the new generic metrics table (here)
|
||||
// this enables thorough testing without risking data loss.
|
||||
|
||||
for (final GenericTrainingLoadAcuteSample legacy : trainingLoadAcuteSamples) {
|
||||
GenericMetricSample metric = new GenericMetricSample();
|
||||
metric.setTimestamp(legacy.getTimestamp());
|
||||
metric.setMetric(MetricSample.Metric.GENERIC_TRAINING_LOAD_ACUTE, legacy.getValue());
|
||||
genericMetricSamples.add(metric);
|
||||
}
|
||||
|
||||
for (final GenericTrainingLoadChronicSample legacy : trainingLoadChronicSamples) {
|
||||
final GenericMetricSample metric = new GenericMetricSample();
|
||||
metric.setTimestamp(legacy.getTimestamp());
|
||||
metric.setMetric(MetricSample.Metric.GENERIC_TRAINING_LOAD_CHRONIC, legacy.getValue());
|
||||
genericMetricSamples.add(metric);
|
||||
}
|
||||
|
||||
for (final GarminRestingMetabolicRateSample legacy : restingMetabolicRateSamples) {
|
||||
final GenericMetricSample metric = new GenericMetricSample();
|
||||
metric.setTimestamp(legacy.getTimestamp());
|
||||
metric.setMetric(MetricSample.Metric.GENERIC_RESTING_METABOLIC_RATE, legacy.getRestingMetabolicRate());
|
||||
genericMetricSamples.add(metric);
|
||||
}
|
||||
|
||||
final GenericMetricSampleProvider provider = new GenericMetricSampleProvider(gbDevice, session);
|
||||
persistAbstractSamples(genericMetricSamples, provider);
|
||||
}
|
||||
|
||||
private void persistWorkout(final File file, final DaoSession session, boolean isReprocessing, FitFile fitFile) {
|
||||
@@ -681,6 +705,8 @@ public class FitImporter {
|
||||
}
|
||||
|
||||
workoutParser.updateSummary(summary);
|
||||
final GenericMetricSampleProvider provider = new GenericMetricSampleProvider(gbDevice, session);
|
||||
persistAbstractSamples(workoutParser.getGenericMetricSamples(), provider);
|
||||
|
||||
summary.setRawDetailsPath(file.getAbsolutePath());
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -2773,6 +2775,7 @@
|
||||
<string name="unit_volt">V</string>
|
||||
<string name="unit_bar">bar</string>
|
||||
<string name="unit_liter">L</string>
|
||||
<string name="unit_kcal_per_day">kcal/day</string>
|
||||
<string name="diving_tank">Tank</string>
|
||||
<!-- consumed volume or pressure from a diving gas tank -->
|
||||
<string name="diving_tank_used">Used</string>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user