1
0
mirror of https://codeberg.org/Freeyourgadget/Gadgetbridge synced 2024-12-26 10:35:50 +01:00

VO2Max: initalize activity

This commit is contained in:
a0z 2024-09-13 21:04:42 +02:00 committed by José Rebelo
parent e2be851097
commit d440ec1e36
20 changed files with 825 additions and 176 deletions

View File

@ -127,7 +127,7 @@ public class GBApplication extends Application {
private static SharedPreferences sharedPrefs;
private static final String PREFS_VERSION = "shared_preferences_version";
//if preferences have to be migrated, increment the following and add the migration logic in migratePrefs below; see http://stackoverflow.com/questions/16397848/how-can-i-migrate-android-preferences-with-a-new-version
private static final int CURRENT_PREFS_VERSION = 39;
private static final int CURRENT_PREFS_VERSION = 40;
private static final LimitedQueue<Integer, String> mIDSenderLookup = new LimitedQueue<>(16);
private static GBPrefs prefs;
@ -1793,6 +1793,36 @@ public class GBApplication extends Application {
}
}
if (oldVersion < 40) {
// Add the new VO2Max tab to all devices
try (DBHandler db = acquireDB()) {
final DaoSession daoSession = db.getDaoSession();
final List<Device> activeDevices = DBHelper.getActiveDevices(daoSession);
for (final Device dbDevice : activeDevices) {
final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier());
final String chartsTabsValue = deviceSharedPrefs.getString("charts_tabs", null);
if (chartsTabsValue == null) {
continue;
}
final String newPrefValue;
if (!StringUtils.isBlank(chartsTabsValue)) {
newPrefValue = chartsTabsValue + ",vo2max";
} else {
newPrefValue = "vo2max";
}
final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit();
deviceSharedPrefsEdit.putString("charts_tabs", newPrefValue);
deviceSharedPrefsEdit.apply();
}
} catch (Exception e) {
Log.e(TAG, "Failed to migrate prefs to version 40", e);
}
}
editor.putString(PREFS_VERSION, Integer.toString(CURRENT_PREFS_VERSION));
editor.apply();
}

View File

@ -30,6 +30,7 @@ import com.github.mikephil.charting.interfaces.datasets.ILineDataSet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.GregorianCalendar;
@ -186,7 +187,7 @@ public abstract class AbstractActivityChartFragment<D extends ChartsData> extend
if (samples.isEmpty()) {
lineData = new LineData();
ValueFormatter xValueFormatter = new SampleXLabelFormatter(tsTranslation);
ValueFormatter xValueFormatter = new SampleXLabelFormatter(tsTranslation, "HH:mm");
return new DefaultChartsData<>(lineData, xValueFormatter);
}
@ -198,7 +199,7 @@ public abstract class AbstractActivityChartFragment<D extends ChartsData> extend
for (int i = 0; i < 6; i++) {
entries.add(new ArrayList<>());
}
boolean hr = supportsHeartrate(gbDevice);
List<Entry> heartrateEntries = hr ? new ArrayList<Entry>(numEntries) : null;
@ -270,7 +271,7 @@ public abstract class AbstractActivityChartFragment<D extends ChartsData> extend
lineData = new LineData(lineDataSets);
ValueFormatter xValueFormatter = new SampleXLabelFormatter(tsTranslation);
ValueFormatter xValueFormatter = new SampleXLabelFormatter(tsTranslation, "HH:mm");
return new DefaultChartsData<>(lineData, xValueFormatter);
}

View File

@ -130,6 +130,9 @@ public class ActivityChartsActivity extends AbstractChartsActivity {
if (!coordinator.supportsBodyEnergy()) {
tabList.remove("bodyenergy");
}
if (!coordinator.supportsVO2Max()) {
tabList.remove("vo2max");
}
return tabList;
}
@ -164,6 +167,8 @@ public class ActivityChartsActivity extends AbstractChartsActivity {
return new HRVStatusFragment();
case "bodyenergy":
return new BodyEnergyFragment();
case "vo2max":
return new VO2MaxFragment();
case "stress":
return new StressChartFragment();
case "pai":
@ -207,6 +212,8 @@ public class ActivityChartsActivity extends AbstractChartsActivity {
return getString(R.string.pref_header_hrv_status);
case "bodyenergy":
return getString(R.string.body_energy);
case "vo2max":
return getString(R.string.vo2max);
case "stress":
return getString(R.string.menuitem_stress);
case "pai":

View File

@ -47,6 +47,7 @@ import com.github.mikephil.charting.utils.Utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
@ -465,7 +466,7 @@ public class LiveActivityFragment extends AbstractActivityChartFragment<ChartsDa
x.setDrawGridLines(false);
x.setEnabled(true);
x.setTextColor(CHART_TEXT_COLOR);
x.setValueFormatter(new SampleXLabelFormatter(tsTranslation));
x.setValueFormatter(new SampleXLabelFormatter(tsTranslation, "HH:mm"));
x.setDrawLimitLinesBehindData(true);
YAxis y = chart.getAxisLeft();

View File

@ -28,12 +28,13 @@ import java.util.GregorianCalendar;
class SampleXLabelFormatter extends ValueFormatter {
private final TimestampTranslation tsTranslation;
@SuppressLint("SimpleDateFormat")
private final SimpleDateFormat annotationDateFormat = new SimpleDateFormat("HH:mm");
private final SimpleDateFormat annotationDateFormat;
// SimpleDateFormat dateFormat = new SimpleDateFormat("dd.MM.yyyy HH:mm");
private final Calendar cal = GregorianCalendar.getInstance();
public SampleXLabelFormatter(final TimestampTranslation tsTranslation) {
public SampleXLabelFormatter(final TimestampTranslation tsTranslation, String simpleDateFormatPattern) {
this.tsTranslation = tsTranslation;
this.annotationDateFormat = new SimpleDateFormat(simpleDateFormatPattern);
}
// TODO: this does not work. Cannot use precomputed labels

View File

@ -41,6 +41,7 @@ import com.github.mikephil.charting.interfaces.datasets.ILineDataSet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@ -256,7 +257,7 @@ public class Spo2ChartFragment extends AbstractChartFragment<Spo2ChartFragment.S
lineDataSets.add(createDataSet(lineEntries));
final LineData lineData = new LineData(lineDataSets);
final ValueFormatter xValueFormatter = new SampleXLabelFormatter(tsTranslation);
final ValueFormatter xValueFormatter = new SampleXLabelFormatter(tsTranslation, "HH:mm");
final DefaultChartsData<LineData> chartsData = new DefaultChartsData<>(lineData, xValueFormatter);
return new Spo2ChartsData(chartsData, Math.round((float) averageSum / averageNumSamples));
}

View File

@ -153,7 +153,7 @@ public class StepsDailyFragment extends StepsFragment<StepsDailyFragment.StepsDa
lineEntries.add(new Entry(tsTranslation.shorten(sample.getTimestamp()), sum));
}
stepsChart.getXAxis().setValueFormatter(new SampleXLabelFormatter(tsTranslation));
stepsChart.getXAxis().setValueFormatter(new SampleXLabelFormatter(tsTranslation, "HH:mm"));
if (sum < STEPS_GOAL) {
stepsChart.getAxisLeft().setAxisMaximum(STEPS_GOAL);

View File

@ -484,7 +484,7 @@ public class StressChartFragment extends AbstractChartFragment<StressChartFragme
final PieData pieData = new PieData(pieDataSet);
final LineData lineData = new LineData(lineDataSets);
final ValueFormatter xValueFormatter = new SampleXLabelFormatter(tsTranslation);
final ValueFormatter xValueFormatter = new SampleXLabelFormatter(tsTranslation, "HH:mm");
final DefaultChartsData<LineData> chartsData = new DefaultChartsData<>(lineData, xValueFormatter);
return new StressChartsData(pieData, chartsData, Math.round((float) averageSum / averageNumSamples), stressZoneTimes);
}

View File

@ -0,0 +1,351 @@
package nodomain.freeyourgadget.gadgetbridge.activities.charts;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.GridLayout;
import android.widget.ImageView;
import android.widget.RelativeLayout;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import com.github.mikephil.charting.charts.Chart;
import com.github.mikephil.charting.charts.LineChart;
import com.github.mikephil.charting.components.XAxis;
import com.github.mikephil.charting.components.YAxis;
import com.github.mikephil.charting.data.Entry;
import com.github.mikephil.charting.data.LineData;
import com.github.mikephil.charting.data.LineDataSet;
import com.github.mikephil.charting.formatter.ValueFormatter;
import com.github.mikephil.charting.interfaces.datasets.ILineDataSet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.GaugeDrawer;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.Vo2MaxSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.Vo2MaxSample;
public class VO2MaxFragment extends AbstractChartFragment<VO2MaxFragment.VO2MaxData> {
protected static final Logger LOG = LoggerFactory.getLogger(VO2MaxFragment.class);
private TextView mDateView;
private TextView vo2MaxGeneralValue;
private TextView vo2MaxRunningValue;
private TextView vo2MaxCyclingValue;
private ImageView vo2MaxGeneralGauge;
private ImageView vo2MaxRunningGauge;
private ImageView vo2MaxCyclingGauge;
protected GaugeDrawer gaugeDrawer = new GaugeDrawer();
private LineChart vo2MaxChart;
private RelativeLayout vo2maxCyclingWrapper;
private RelativeLayout vo2maxRunningWrapper;
private RelativeLayout vo2maxGeneralWrapper;
private GridLayout tilesGridWrapper;
private int tsFrom;
GBDevice device;
protected int CHART_TEXT_COLOR;
protected int LEGEND_TEXT_COLOR;
protected int TEXT_COLOR;
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.fragment_vo2max, container, false);
mDateView = rootView.findViewById(R.id.vo2max_date_view);
vo2MaxGeneralValue = rootView.findViewById(R.id.vo2max_general_gauge_value);
vo2MaxRunningValue = rootView.findViewById(R.id.vo2max_running_gauge_value);
vo2MaxCyclingValue = rootView.findViewById(R.id.vo2max_cycling_gauge_value);
vo2MaxGeneralGauge = rootView.findViewById(R.id.vo2max_general_gauge);
vo2MaxRunningGauge = rootView.findViewById(R.id.vo2max_running_gauge);
vo2MaxCyclingGauge = rootView.findViewById(R.id.vo2max_cycling_gauge);
vo2MaxChart = rootView.findViewById(R.id.vo2max_chart);
vo2maxCyclingWrapper = rootView.findViewById(R.id.vo2max_cycling_card_layout);
vo2maxGeneralWrapper = rootView.findViewById(R.id.vo2max_general_card_layout);
vo2maxRunningWrapper = rootView.findViewById(R.id.vo2max_running_card_layout);
tilesGridWrapper = rootView.findViewById(R.id.tiles_grid_wrapper);
device = getChartsHost().getDevice();
if (!supportsVO2MaxCycling(device)) {
tilesGridWrapper.removeView(vo2maxCyclingWrapper);
}
if (!supportsVO2MaxRunning(device)) {
tilesGridWrapper.removeView(vo2maxRunningWrapper);
}
if (!supportsVO2MaxGeneral(device)) {
tilesGridWrapper.removeView(vo2maxGeneralWrapper);
}
setupVO2MaxChart();
refresh();
return rootView;
}
public boolean supportsVO2MaxCycling(GBDevice device) {
DeviceCoordinator coordinator = device.getDeviceCoordinator();
return coordinator != null && coordinator.supportsVO2MaxCycling();
}
public boolean supportsVO2MaxGeneral(GBDevice device) {
DeviceCoordinator coordinator = device.getDeviceCoordinator();
return coordinator != null && coordinator.supportsVO2MaxGeneral();
}
public boolean supportsVO2MaxRunning(GBDevice device) {
DeviceCoordinator coordinator = device.getDeviceCoordinator();
return coordinator != null && coordinator.supportsVO2MaxRunning();
}
@Override
public String getTitle() {
return getString(R.string.vo2max);
}
@Override
protected void init() {
TEXT_COLOR = GBApplication.getTextColor(requireContext());
LEGEND_TEXT_COLOR = GBApplication.getTextColor(requireContext());
CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext());
}
@Override
protected VO2MaxData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) {
String formattedDate = new SimpleDateFormat("E, MMM dd").format(getEndDate());
mDateView.setText(formattedDate);
List<VO2MaxRecord> records = new ArrayList<>();
int tsEnd = getTSEnd();
Calendar day = Calendar.getInstance();
day.setTimeInMillis(tsEnd * 1000L); //we need today initially, which is the end of the time range
day.set(Calendar.HOUR_OF_DAY, 0); //and we set time for the start and end of the same day
day.set(Calendar.MINUTE, 0);
day.set(Calendar.SECOND, 0);
day.add(Calendar.DAY_OF_YEAR, -30);
tsFrom = (int) (day.getTimeInMillis() / 1000);
List<? extends Vo2MaxSample> samples = getAllSamples(db, device, tsFrom, tsEnd);
for (Vo2MaxSample sample : samples) {
records.add(new VO2MaxRecord(sample.getTimestamp() / 1000, sample.getValue(), sample.getType()));
}
Map<Vo2MaxSample.Type, VO2MaxRecord> latestValues = new HashMap<>();
for (Vo2MaxSample.Type type : Vo2MaxSample.Type.values()) {
Vo2MaxSample sample = getLatestVo2MaxSample(db, device, type);
if (sample != null) {
latestValues.put(type, new VO2MaxRecord(sample.getTimestamp() / 1000, sample.getValue(), type));
}
}
return new VO2MaxData(records, latestValues);
}
@Override
protected void updateChartsnUIThread(VO2MaxData vo2MaxData) {
TimestampTranslation tsTranslation = new TimestampTranslation();
List<Entry> runningEntries = new ArrayList<>();
List<Entry> cyclingEntries = new ArrayList<>();
List<Entry> generalEntries = new ArrayList<>();
vo2MaxData.records.forEach((record) -> {
float nd = (float) (record.timestamp - this.tsFrom) / (60 * 60 * 24);
switch (record.type) {
case RUNNING:
runningEntries.add(new Entry(nd, record.value));
break;
case CYCLING:
cyclingEntries.add(new Entry(nd, record.value));
break;
case GENERAL:
generalEntries.add(new Entry(nd, record.value));
break;
}
});
final int[] colors = {
ContextCompat.getColor(GBApplication.getContext(), R.color.vo2max_value_poor_color),
ContextCompat.getColor(GBApplication.getContext(), R.color.vo2max_value_fair_color),
ContextCompat.getColor(GBApplication.getContext(), R.color.vo2max_value_good_color),
ContextCompat.getColor(GBApplication.getContext(), R.color.vo2max_value_excellent_color),
ContextCompat.getColor(GBApplication.getContext(), R.color.vo2max_value_superior_color),
};
final float[] segments = {
0.20F,
0.20F,
0.20F,
0.20F,
0.20F,
};
float[] vo2MaxRanges = {
55.4F,
51.1F,
45.4F,
41.7F,
0.0F,
};
final List<ILineDataSet> lineDataSets = new ArrayList<>();
if (supportsVO2MaxGeneral(device)) {
VO2MaxRecord latestGeneralRecord = vo2MaxData.getLatestValue(Vo2MaxSample.Type.GENERAL);
float generalVO2MaxValue = calculateVO2maxGaugeValue(vo2MaxRanges, latestGeneralRecord != null ? latestGeneralRecord.value : 0);
gaugeDrawer.drawSegmentedGauge(vo2MaxGeneralGauge, colors, segments, generalVO2MaxValue, false, true);
vo2MaxGeneralValue.setText(String.valueOf(latestGeneralRecord != null ? Math.round(latestGeneralRecord.value) : "-"));
lineDataSets.add(createDataSet(generalEntries, getResources().getColor(R.color.vo2max_general_char_line_color), getString(R.string.vo2_max_general)));
}
if (supportsVO2MaxRunning(device)) {
VO2MaxRecord latestRunningRecord = vo2MaxData.getLatestValue(Vo2MaxSample.Type.RUNNING);
float runningVO2MaxValue = calculateVO2maxGaugeValue(vo2MaxRanges, latestRunningRecord != null ? latestRunningRecord.value : 0);
vo2MaxRunningValue.setText(String.valueOf(latestRunningRecord != null ? Math.round(latestRunningRecord.value) : "-"));
gaugeDrawer.drawSegmentedGauge(vo2MaxRunningGauge, colors, segments, runningVO2MaxValue, false, true);
lineDataSets.add(createDataSet(runningEntries, getResources().getColor(R.color.vo2max_running_char_line_color), getString(R.string.vo2_max_running)));
}
if (supportsVO2MaxCycling(device)) {
VO2MaxRecord latestCyclingRecord = vo2MaxData.getLatestValue(Vo2MaxSample.Type.CYCLING);
float cyclingVO2MaxValue = calculateVO2maxGaugeValue(vo2MaxRanges, latestCyclingRecord != null ? latestCyclingRecord.value : 0);
gaugeDrawer.drawSegmentedGauge(vo2MaxCyclingGauge, colors, segments, cyclingVO2MaxValue, false, true);
vo2MaxCyclingValue.setText(String.valueOf(latestCyclingRecord != null ? Math.round(latestCyclingRecord.value) : "-"));
lineDataSets.add(createDataSet(cyclingEntries, getResources().getColor(R.color.vo2max_cycling_char_line_color), getString(R.string.vo2_max_cycling)));
}
final LineData lineData = new LineData(lineDataSets);
vo2MaxChart.getXAxis().setValueFormatter(getVO2MaxLineChartValueFormatter());
vo2MaxChart.setData(lineData);
}
ValueFormatter getVO2MaxLineChartValueFormatter() {
return new ValueFormatter() {
@Override
public String getFormattedValue(float value) {
Calendar day = Calendar.getInstance();
day.setTimeInMillis(tsFrom * 1000L);
day.add(Calendar.DAY_OF_YEAR, (int) value);
return new SimpleDateFormat("dd/MM").format(day.getTime());
}
};
}
private float calculateVO2maxGaugeValue(float[] vo2MaxRanges, float vo2MaxValue) {
float value = -1;
for (int i = 0; i < vo2MaxRanges.length; i++) {
if (vo2MaxValue - vo2MaxRanges[i] > 0) {
float rangeValue = i - 1 >= 0 ? vo2MaxRanges[i-1] : 60F;
float rangeDiff = rangeValue - vo2MaxRanges[i];
float valueDiff = vo2MaxValue - vo2MaxRanges[i];
float multiplayer = valueDiff / rangeDiff;
value = (4 - i) * 0.2F + 0.2F * (multiplayer > 1 ? 1 : multiplayer) ;
break;
}
}
return value;
}
protected LineDataSet createDataSet(final List<Entry> values, int color, String label) {
final LineDataSet lineDataSet = new LineDataSet(values, label);
lineDataSet.setColor(color);
lineDataSet.setDrawCircles(false);
lineDataSet.setLineWidth(2f);
lineDataSet.setFillAlpha(255);
lineDataSet.setCircleRadius(5f);
lineDataSet.setDrawCircles(true);
lineDataSet.setDrawCircleHole(true);
lineDataSet.setCircleColor(color);
lineDataSet.setAxisDependency(YAxis.AxisDependency.LEFT);
lineDataSet.setDrawValues(true);
lineDataSet.setValueTextSize(10f);
lineDataSet.setValueTextColor(CHART_TEXT_COLOR);
lineDataSet.setValueFormatter(new ValueFormatter() {
@Override
public String getFormattedValue(float value) {
return String.format(Locale.ROOT, "%d", Math.round(value));
}
});
return lineDataSet;
}
@Override
protected void renderCharts() {
vo2MaxChart.invalidate();
}
public List<? extends Vo2MaxSample> getAllSamples(final DBHandler db, final GBDevice device, int tsFrom, int tsTo) {
final DeviceCoordinator coordinator = device.getDeviceCoordinator();
final TimeSampleProvider<? extends Vo2MaxSample> sampleProvider = coordinator.getVo2MaxSampleProvider(device, db.getDaoSession());
return sampleProvider.getAllSamples(tsFrom * 1000L, tsTo * 1000L);
}
public Vo2MaxSample getLatestVo2MaxSample(final DBHandler db, final GBDevice device, Vo2MaxSample.Type type) {
final DeviceCoordinator coordinator = device.getDeviceCoordinator();
final Vo2MaxSampleProvider sampleProvider = (Vo2MaxSampleProvider) coordinator.getVo2MaxSampleProvider(device, db.getDaoSession());
return sampleProvider.getLatestSample(type);
}
private void setupVO2MaxChart() {
final XAxis xAxisBottom = vo2MaxChart.getXAxis();
xAxisBottom.setPosition(XAxis.XAxisPosition.BOTTOM);
xAxisBottom.setDrawLabels(true);
xAxisBottom.setDrawGridLines(false);
xAxisBottom.setEnabled(true);
xAxisBottom.setDrawLimitLinesBehindData(true);
xAxisBottom.setTextColor(CHART_TEXT_COLOR);
xAxisBottom.setAxisMinimum(0f);
xAxisBottom.setAxisMaximum(31f);
xAxisBottom.setGranularity(1f);
xAxisBottom.setGranularityEnabled(true);
final YAxis yAxisLeft = vo2MaxChart.getAxisLeft();
yAxisLeft.setDrawGridLines(true);
yAxisLeft.setAxisMaximum(100);
yAxisLeft.setAxisMinimum(0);
yAxisLeft.setDrawTopYLabelEntry(true);
yAxisLeft.setEnabled(true);
yAxisLeft.setTextColor(CHART_TEXT_COLOR);
final YAxis yAxisRight = vo2MaxChart.getAxisRight();
yAxisRight.setEnabled(true);
yAxisRight.setDrawLabels(false);
yAxisRight.setDrawGridLines(false);
yAxisRight.setDrawAxisLine(true);
}
protected void setupLegend(Chart<?> chart) {}
protected static class VO2MaxRecord {
float value;
long timestamp;
Vo2MaxSample.Type type;
protected VO2MaxRecord(long timestamp, float value, Vo2MaxSample.Type type) {
this.timestamp = timestamp;
this.value = value;
this.type = type;
}
}
protected static class VO2MaxData extends ChartsData {
private final List<? extends VO2MaxRecord> records;
private final Map<Vo2MaxSample.Type, VO2MaxRecord> latestValues;
protected VO2MaxData(List<? extends VO2MaxRecord> records, Map<Vo2MaxSample.Type, VO2MaxRecord> latestValues) {
this.records = records;
this.latestValues = latestValues;
}
@Nullable
public VO2MaxRecord getLatestValue(Vo2MaxSample.Type type) {
return this.latestValues.get(type);
}
}
}

View File

@ -38,7 +38,6 @@ import androidx.annotation.StringRes;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
@ -47,6 +46,7 @@ public abstract class AbstractGaugeWidget extends AbstractDashboardWidget {
private TextView gaugeValue;
private ImageView gaugeBar;
protected GaugeDrawer gaugeDrawer;
private final int label;
private final String targetActivityTab;
@ -66,6 +66,7 @@ public abstract class AbstractGaugeWidget extends AbstractDashboardWidget {
gaugeValue = fragmentView.findViewById(R.id.gauge_value);
gaugeBar = fragmentView.findViewById(R.id.gauge_bar);
gaugeDrawer = new GaugeDrawer();
final TextView gaugeLabel = fragmentView.findViewById(R.id.gauge_label);
gaugeLabel.setText(label);
@ -143,50 +144,7 @@ public abstract class AbstractGaugeWidget extends AbstractDashboardWidget {
*/
protected void drawSimpleGauge(final int color,
final float value) {
final int width = (int) TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
150,
GBApplication.getContext().getResources().getDisplayMetrics()
);
// Draw gauge
gaugeBar.setImageBitmap(drawSimpleGaugeInternal(
width,
Math.round(width * 0.075f),
color,
value
));
}
/**
* @param width Bitmap width in pixels
* @param barWidth Gauge bar width in pixels
* @param filledColor Color of the filled part of the gauge
* @param filledFactor Factor between 0 and 1 that determines the amount of the gauge that should be filled
* @return Bitmap containing the gauge
*/
private Bitmap drawSimpleGaugeInternal(final int width, final int barWidth, @ColorInt final int filledColor, final float filledFactor) {
final int height = width / 2;
final int barMargin = (int) Math.ceil(barWidth / 2f);
final Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
final Canvas canvas = new Canvas(bitmap);
final Paint paint = new Paint();
paint.setAntiAlias(true);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeCap(Paint.Cap.ROUND);
paint.setStrokeWidth(barWidth * 0.75f);
paint.setColor(color_unknown);
canvas.drawArc(barMargin, barMargin, width - barMargin, width - barMargin, 180 + 180 * filledFactor, 180 - 180 * filledFactor, false, paint);
if (filledFactor >= 0) {
paint.setStrokeWidth(barWidth);
paint.setColor(filledColor);
canvas.drawArc(barMargin, barMargin, width - barMargin, width - barMargin, 180, 180 * filledFactor, false, paint);
}
return bitmap;
gaugeDrawer.drawSimpleGauge(gaugeBar, color, value);
}
/**
@ -203,116 +161,6 @@ public abstract class AbstractGaugeWidget extends AbstractDashboardWidget {
final float value,
final boolean fadeOutsideDot,
final boolean gapBetweenSegments) {
if (colors.length != segments.length) {
LOG.error("Colors length {} differs from segments length {}", colors.length, segments.length);
return;
}
final int width = (int) TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
150,
GBApplication.getContext().getResources().getDisplayMetrics()
);
final int barWidth = Math.round(width * 0.075f);
final int height = width / 2;
final int barMargin = (int) Math.ceil(barWidth / 2f);
final Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
final Canvas canvas = new Canvas(bitmap);
final Paint paint = new Paint();
paint.setAntiAlias(true);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeCap(Paint.Cap.BUTT);
paint.setStrokeWidth(barWidth);
final double cornersGapRadians = Math.asin((width * 0.055f) / (double) height);
final double cornersGapFactor = cornersGapRadians / Math.PI;
int dotColor = 0;
float angleSum = 0;
for (int i = 0; i < segments.length; i++) {
if (segments[i] == 0) {
continue;
}
paint.setColor(colors[i]);
paint.setStrokeWidth(barWidth);
if (value < 0 || (value >= angleSum && value <= angleSum + segments[i])) {
dotColor = colors[i];
} else {
if (fadeOutsideDot) {
paint.setColor(colors[i] - 0xB0000000);
} else {
paint.setStrokeWidth(barWidth * 0.75f);
}
}
float startAngleDegrees = 180 + angleSum * 180;
float sweepAngleDegrees = segments[i] * 180;
if (value >= 0) {
// Do not draw to the end if it will be overlapped by the dot
if (i == 0 && value <= cornersGapFactor) {
startAngleDegrees += (float) Math.toDegrees(cornersGapRadians);
sweepAngleDegrees -= (float) Math.toDegrees(cornersGapRadians);
} else if (i == segments.length - 1 && value >= 1 - cornersGapFactor) {
sweepAngleDegrees -= (float) Math.toDegrees(cornersGapRadians);
}
}
if (gapBetweenSegments) {
if (i + 1 < segments.length) {
sweepAngleDegrees -= 2;
}
}
canvas.drawArc(
barMargin,
barMargin,
width - barMargin,
width - barMargin,
startAngleDegrees,
sweepAngleDegrees,
false,
paint
);
angleSum += segments[i];
}
if (value >= 0) {
// Prevent the dot from going outside the widget in the extremities
final float angleRadians = (float) normalize(value, 0, 1, cornersGapRadians, Math.toRadians(180) - cornersGapRadians);
paint.setColor(Color.TRANSPARENT);
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
// In the corners the circle is slightly offset, so adjust it slightly
final float widthAdjustment = width * 0.04f * (float) normalize(Math.abs(value - 0.5d), 0, 0.5d);
final float x = ((width - (barWidth / 2f) - widthAdjustment) / 2f) * (float) Math.cos(angleRadians);
final float y = (height - (barWidth / 2f)) * (float) Math.sin(angleRadians);
// Draw hole
paint.setStyle(Paint.Style.FILL);
canvas.drawCircle((width / 2f) - x, height - y, barMargin * 1.6f, paint);
// Draw dot
paint.setColor(dotColor);
paint.setXfermode(null);
canvas.drawCircle((width / 2f) - x, height - y, barMargin, paint);
}
gaugeBar.setImageBitmap(bitmap);
}
protected static double normalize(final double value, final double min, final double max) {
return normalize(value, min, max, 0, 1);
}
public static double normalize(final double value, final double minSource, final double maxSource, final double minTarget, final double maxTarget) {
return ((value - minSource) * (maxTarget - minTarget)) / (maxSource - minSource) + minTarget;
gaugeDrawer.drawSegmentedGauge(gaugeBar, colors, segments, value, fadeOutsideDot, gapBetweenSegments);
}
}

View File

@ -111,13 +111,13 @@ public class DashboardHrvWidget extends AbstractGaugeWidget {
valueText = getString(R.string.hrv_status_unit, hrvData.weeklyAverage);
if (hrvData.weeklyAverage < hrvData.baselineLowUpper) {
value = 0.125f * (float) normalize(hrvData.weeklyAverage, 0f, hrvData.baselineLowUpper);
value = 0.125f * (float) GaugeDrawer.normalize(hrvData.weeklyAverage, 0f, hrvData.baselineLowUpper);
} else if (hrvData.weeklyAverage < hrvData.baselineBalancedLower) {
value = 0.125f + 0.125f * (float) normalize((float) hrvData.weeklyAverage, hrvData.baselineLowUpper, hrvData.baselineBalancedLower);
value = 0.125f + 0.125f * (float) GaugeDrawer.normalize((float) hrvData.weeklyAverage, hrvData.baselineLowUpper, hrvData.baselineBalancedLower);
} else if (hrvData.weeklyAverage < hrvData.baselineBalancedUpper) {
value = 0.125f + 0.125f + 0.5f * (float) normalize((float) hrvData.weeklyAverage, hrvData.baselineBalancedLower, hrvData.baselineBalancedUpper);
value = 0.125f + 0.125f + 0.5f * (float) GaugeDrawer.normalize((float) hrvData.weeklyAverage, hrvData.baselineBalancedLower, hrvData.baselineBalancedUpper);
} else {
value = 0.125f + 0.125f + 0.5f + 0.125f * (float) normalize((float) hrvData.weeklyAverage, hrvData.baselineBalancedUpper, 2 * hrvData.baselineBalancedUpper);
value = 0.125f + 0.125f + 0.5f + 0.125f * (float) GaugeDrawer.normalize((float) hrvData.weeklyAverage, hrvData.baselineBalancedUpper, 2 * hrvData.baselineBalancedUpper);
}
} else {
value = -1;

View File

@ -0,0 +1,205 @@
package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.util.TypedValue;
import android.widget.ImageView;
import androidx.annotation.ColorInt;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
public class GaugeDrawer {
private static final Logger LOG = LoggerFactory.getLogger(GaugeDrawer.class);
protected @ColorInt int color_unknown = Color.argb(25, 128, 128, 128);
/**
* Draw a simple gauge.
*
* @param color the gauge color
* @param value the gauge value. Range: [0, 1]
*/
public void drawSimpleGauge(ImageView gaugeBar, final int color,
final float value) {
final int width = (int) TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
150,
GBApplication.getContext().getResources().getDisplayMetrics()
);
// Draw gauge
gaugeBar.setImageBitmap(drawSimpleGaugeInternal(
width,
Math.round(width * 0.075f),
color,
value
));
}
/**
* @param width Bitmap width in pixels
* @param barWidth Gauge bar width in pixels
* @param filledColor Color of the filled part of the gauge
* @param filledFactor Factor between 0 and 1 that determines the amount of the gauge that should be filled
* @return Bitmap containing the gauge
*/
private Bitmap drawSimpleGaugeInternal(final int width, final int barWidth, @ColorInt final int filledColor, final float filledFactor) {
final int height = width / 2;
final int barMargin = (int) Math.ceil(barWidth / 2f);
final Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
final Canvas canvas = new Canvas(bitmap);
final Paint paint = new Paint();
paint.setAntiAlias(true);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeCap(Paint.Cap.ROUND);
paint.setStrokeWidth(barWidth * 0.75f);
paint.setColor(color_unknown);
canvas.drawArc(barMargin, barMargin, width - barMargin, width - barMargin, 180 + 180 * filledFactor, 180 - 180 * filledFactor, false, paint);
if (filledFactor >= 0) {
paint.setStrokeWidth(barWidth);
paint.setColor(filledColor);
canvas.drawArc(barMargin, barMargin, width - barMargin, width - barMargin, 180, 180 * filledFactor, false, paint);
}
return bitmap;
}
/**
* Draws a segmented gauge.
*
* @param colors the colors of each segment
* @param segments the size of each segment. The sum of all segments should be 1
* @param value the gauge value, in range [0, 1], or -1 for no value and only segments
* @param fadeOutsideDot whether to fade out colors outside the dot value
* @param gapBetweenSegments whether to introduce a small gap between the segments
*/
public void drawSegmentedGauge(ImageView gaugeBar,
final int[] colors,
final float[] segments,
final float value,
final boolean fadeOutsideDot,
final boolean gapBetweenSegments) {
if (colors.length != segments.length) {
LOG.error("Colors length {} differs from segments length {}", colors.length, segments.length);
return;
}
final int width = (int) TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
150,
GBApplication.getContext().getResources().getDisplayMetrics()
);
final int barWidth = Math.round(width * 0.075f);
final int height = width / 2;
final int barMargin = (int) Math.ceil(barWidth / 2f);
final Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
final Canvas canvas = new Canvas(bitmap);
final Paint paint = new Paint();
paint.setAntiAlias(true);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeCap(Paint.Cap.BUTT);
paint.setStrokeWidth(barWidth);
final double cornersGapRadians = Math.asin((width * 0.055f) / (double) height);
final double cornersGapFactor = cornersGapRadians / Math.PI;
int dotColor = 0;
float angleSum = 0;
for (int i = 0; i < segments.length; i++) {
if (segments[i] == 0) {
continue;
}
paint.setColor(colors[i]);
paint.setStrokeWidth(barWidth);
if (value < 0 || (value >= angleSum && value <= angleSum + segments[i])) {
dotColor = colors[i];
} else {
if (fadeOutsideDot) {
paint.setColor(colors[i] - 0xB0000000);
} else {
paint.setStrokeWidth(barWidth * 0.75f);
}
}
float startAngleDegrees = 180 + angleSum * 180;
float sweepAngleDegrees = segments[i] * 180;
if (value >= 0) {
// Do not draw to the end if it will be overlapped by the dot
if (i == 0 && value <= cornersGapFactor) {
startAngleDegrees += (float) Math.toDegrees(cornersGapRadians);
sweepAngleDegrees -= (float) Math.toDegrees(cornersGapRadians);
} else if (i == segments.length - 1 && value >= 1 - cornersGapFactor) {
sweepAngleDegrees -= (float) Math.toDegrees(cornersGapRadians);
}
}
if (gapBetweenSegments) {
if (i + 1 < segments.length) {
sweepAngleDegrees -= 2;
}
}
canvas.drawArc(
barMargin,
barMargin,
width - barMargin,
width - barMargin,
startAngleDegrees,
sweepAngleDegrees,
false,
paint
);
angleSum += segments[i];
}
if (value >= 0) {
// Prevent the dot from going outside the widget in the extremities
final float angleRadians = (float) normalize(value, 0, 1, cornersGapRadians, Math.toRadians(180) - cornersGapRadians);
paint.setColor(Color.TRANSPARENT);
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
// In the corners the circle is slightly offset, so adjust it slightly
final float widthAdjustment = width * 0.04f * (float) normalize(Math.abs(value - 0.5d), 0, 0.5d);
final float x = ((width - (barWidth / 2f) - widthAdjustment) / 2f) * (float) Math.cos(angleRadians);
final float y = (height - (barWidth / 2f)) * (float) Math.sin(angleRadians);
// Draw hole
paint.setStyle(Paint.Style.FILL);
canvas.drawCircle((width / 2f) - x, height - y, barMargin * 1.6f, paint);
// Draw dot
paint.setColor(dotColor);
paint.setXfermode(null);
canvas.drawCircle((width / 2f) - x, height - y, barMargin, paint);
}
gaugeBar.setImageBitmap(bitmap);
}
public static double normalize(final double value, final double min, final double max) {
return normalize(value, min, max, 0, 1);
}
public static double normalize(final double value, final double minSource, final double maxSource, final double minTarget, final double maxTarget) {
return ((value - minSource) * (maxTarget - minTarget)) / (maxSource - minSource) + minTarget;
}
}

View File

@ -476,7 +476,22 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator {
}
@Override
public boolean supportsVo2Max() {
public boolean supportsVO2Max() {
return false;
}
@Override
public boolean supportsVO2MaxCycling() {
return false;
}
@Override
public boolean supportsVO2MaxRunning() {
return false;
}
@Override
public boolean supportsVO2MaxGeneral() {
return false;
}

View File

@ -218,11 +218,11 @@ public interface DeviceCoordinator {
boolean supportsStressMeasurement();
boolean supportsBodyEnergy();
boolean supportsHrvMeasurement();
boolean supportsVo2Max();
boolean supportsVO2Max();
boolean supportsVO2MaxCycling();
boolean supportsVO2MaxGeneral();
boolean supportsVO2MaxRunning();
boolean supportsSleepMeasurement();
boolean supportsStepCounter();
boolean supportsSpeedzones();

View File

@ -218,7 +218,17 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
}
@Override
public boolean supportsVo2Max() {
public boolean supportsVO2Max() {
return true;
}
@Override
public boolean supportsVO2MaxCycling() {
return true;
}
@Override
public boolean supportsVO2MaxRunning() {
return true;
}

View File

@ -0,0 +1,157 @@
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="nodomain.freeyourgadget.gadgetbridge.activities.VO2Max">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/vo2max_date_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textSize="20sp"
android:layout_marginTop="15dp"
android:layout_marginBottom="20dp"
/>
<GridLayout
android:id="@+id/tiles_grid_wrapper"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:columnCount="2"
>
<RelativeLayout
android:id="@+id/vo2max_general_card_layout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:orientation="vertical"
tools:ignore="UselessParent"
android:layout_gravity="fill"
android:layout_columnWeight="1"
>
<ImageView
android:id="@+id/vo2max_general_gauge"
android:layout_width="150dp"
android:layout_height="75dp"
android:layout_centerHorizontal="true"
android:scaleType="fitStart" />
<TextView
android:id="@+id/vo2max_general_gauge_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_marginTop="28dp"
android:text="@string/stats_empty_value"
android:textSize="30sp" />
<TextView
android:id="@+id/vo2max_general_gauge_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/vo2max_general_gauge_value"
android:layout_centerHorizontal="true"
android:text="@string/vo2max_general_gauge_label" />
</RelativeLayout>
<RelativeLayout
android:id="@+id/vo2max_running_card_layout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:orientation="vertical"
tools:ignore="UselessParent"
android:layout_gravity="fill"
android:layout_columnWeight="1"
>
<ImageView
android:id="@+id/vo2max_running_gauge"
android:layout_width="150dp"
android:layout_height="75dp"
android:layout_centerHorizontal="true"
android:scaleType="fitStart" />
<TextView
android:id="@+id/vo2max_running_gauge_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_marginTop="28dp"
android:text="@string/stats_empty_value"
android:textSize="30sp" />
<TextView
android:id="@+id/vo2max_running_gauge_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/vo2max_running_gauge_value"
android:layout_centerHorizontal="true"
android:text="@string/vo2max_running_gauge_label" />
</RelativeLayout>
<RelativeLayout
android:id="@+id/vo2max_cycling_card_layout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:orientation="vertical"
tools:ignore="UselessParent"
android:layout_gravity="fill"
android:layout_columnWeight="1"
>
<ImageView
android:id="@+id/vo2max_cycling_gauge"
android:layout_width="150dp"
android:layout_height="75dp"
android:layout_centerHorizontal="true"
android:scaleType="fitStart" />
<TextView
android:id="@+id/vo2max_cycling_gauge_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_marginTop="28dp"
android:text="@string/stats_empty_value"
android:textSize="30sp" />
<TextView
android:id="@+id/vo2max_cycling_gauge_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/vo2max_cycling_gauge_value"
android:layout_centerHorizontal="true"
android:text="@string/vo2max_cycling_gauge_label" />
</RelativeLayout>
</GridLayout>
<TextView
android:layout_marginTop="30dp"
android:layout_marginStart="25dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="start"
android:textSize="20sp"
android:text="@string/thirty_days_timeline"
/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="250sp"
>
<com.github.mikephil.charting.charts.LineChart
android:id="@+id/vo2max_chart"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:layout_weight="2" />
</LinearLayout>
</LinearLayout>
</RelativeLayout>

View File

@ -3041,6 +3041,7 @@
<item>@string/weekstepschart_steps_a_week_or_month</item>
<item>@string/pref_header_hrv_status</item>
<item>@string/body_energy</item>
<item>@string/vo2max</item>
<item>@string/menuitem_stress</item>
<item>@string/menuitem_pai</item>
<item>@string/stats_title</item>
@ -3057,6 +3058,7 @@
<item>@string/p_steps_week</item>
<item>@string/p_hrv_status</item>
<item>@string/p_body_energy</item>
<item>@string/p_vo2max</item>
<item>@string/p_stress</item>
<item>@string/p_pai</item>
<item>@string/p_speed_zones</item>
@ -3073,6 +3075,7 @@
<item>@string/p_sleep</item>
<item>@string/p_hrv_status</item>
<item>@string/p_body_energy</item>
<item>@string/p_vo2max</item>
<item>@string/p_steps_week</item>
<item>@string/p_stress</item>
<item>@string/p_pai</item>
@ -4174,6 +4177,7 @@
<item>@string/active_time</item>
<item>@string/menuitem_sleep</item>
<item>@string/body_energy</item>
<item>@string/vo2max</item>
<item>@string/menuitem_stress_simple</item>
<item>@string/menuitem_stress_segmented</item>
<item>@string/menuitem_stress_breakdown</item>
@ -4188,6 +4192,7 @@
<item>activetime</item>
<item>sleep</item>
<item>bodyenergy</item>
<item>vo2max</item>
<item>stress_simple</item>
<item>stress_segmented</item>
<item>stress_breakdown</item>

View File

@ -51,6 +51,14 @@
<color name="hrv_status_low" type="color">#fc5203</color>
<color name="hrv_status_poor" type="color">#be03fc</color>
<color name="hrv_status_char_line_color" type="color">#d12a2a</color>
<color name="vo2max_running_char_line_color" type="color">#46acea</color>
<color name="vo2max_cycling_char_line_color" type="color">#59b22c</color>
<color name="vo2max_general_char_line_color" type="color">#824be3</color>
<color name="vo2max_value_poor_color" type="color">#d93832</color>
<color name="vo2max_value_fair_color" type="color">#ffa703</color>
<color name="vo2max_value_good_color" type="color">#04c79c</color>
<color name="vo2max_value_excellent_color" type="color">#02a8e6</color>
<color name="vo2max_value_superior_color" type="color">#824be3</color>
<color name="body_energy_level_color" type="color">#5ac234</color>
<color name="body_energy_lost_color" type="color">#ff6c43</color>
<color name="steps_color" type="color">#00c9bf</color>

View File

@ -1623,6 +1623,9 @@
<string name="hrv_status_unit">%1$d ms</string>
<string name="hrv_status_baseline">%1$d-%2$d ms</string>
<string name="hrv_status_baseline_label">Baseline</string>
<string name="vo2_max_running">VO2Max Running</string>
<string name="vo2_max_cycling">VO2Max Cycling</string>
<string name="vo2_max_general">VO2Max General</string>
<string name="bpm_value_unit">%1$d bpm</string>
<string name="steps_distance_unit">%1$,.2f km</string>
<string name="body_energy_gained">Gained</string>
@ -1990,6 +1993,9 @@
<string name="warning">Warning!</string>
<string name="note">Note</string>
<string name="no_data">No data</string>
<string name="vo2max_general_gauge_label">VO2Max</string>
<string name="vo2max_running_gauge_label">Running VO2Max</string>
<string name="vo2max_cycling_gauge_label">Cycling VO2Max</string>
<!-- LED Color -->
<string name="preferences_led_color">LED Color</string>
<!-- FM transmitters -->
@ -2530,6 +2536,8 @@
<string name="pref_header_spo2">Blood Oxygen</string>
<string name="pref_header_hrv_status">HRV Status</string>
<string name="body_energy">Body Energy</string>
<string name="vo2max">VO2 Max</string>
<string name="thirty_days_timeline">30 Days Timeline</string>
<string name="pref_header_sony_ambient_sound_control">Ambient Sound Control</string>
<string name="pref_header_sony_sound_control">Sound Control</string>
<string name="pref_header_sony_device_info">Device Information</string>

View File

@ -109,6 +109,7 @@
<item name="p_speed_zones" type="string">speedzones</item>
<item name="p_hrv_status" type="string">hrvstatus</item>
<item name="p_body_energy" type="string">bodyenergy</item>
<item name="p_vo2max" type="string">vo2max</item>
<item name="p_live_stats" type="string">livestats</item>
<item name="p_spo2" type="string">spo2</item>
<item name="p_temperature" type="string">temperature</item>