From d440ec1e36bb90ce55dee72061fea87d78272fdf Mon Sep 17 00:00:00 2001 From: a0z Date: Fri, 13 Sep 2024 21:04:42 +0200 Subject: [PATCH] VO2Max: initalize activity --- .../gadgetbridge/GBApplication.java | 32 +- .../charts/AbstractActivityChartFragment.java | 7 +- .../charts/ActivityChartsActivity.java | 7 + .../charts/LiveActivityFragment.java | 3 +- .../charts/SampleXLabelFormatter.java | 5 +- .../activities/charts/Spo2ChartFragment.java | 3 +- .../activities/charts/StepsDailyFragment.java | 2 +- .../charts/StressChartFragment.java | 2 +- .../activities/charts/VO2MaxFragment.java | 351 ++++++++++++++++++ .../dashboard/AbstractGaugeWidget.java | 160 +------- .../dashboard/DashboardHrvWidget.java | 8 +- .../activities/dashboard/GaugeDrawer.java | 205 ++++++++++ .../devices/AbstractDeviceCoordinator.java | 17 +- .../devices/DeviceCoordinator.java | 8 +- .../devices/garmin/GarminCoordinator.java | 12 +- app/src/main/res/layout/fragment_vo2max.xml | 157 ++++++++ app/src/main/res/values/arrays.xml | 5 + app/src/main/res/values/colors.xml | 8 + app/src/main/res/values/strings.xml | 8 + app/src/main/res/values/values.xml | 1 + 20 files changed, 825 insertions(+), 176 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/VO2MaxFragment.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/GaugeDrawer.java create mode 100644 app/src/main/res/layout/fragment_vo2max.xml diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java index 4351ee242..759413e51 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java @@ -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 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 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(); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractActivityChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractActivityChartFragment.java index c001f87a7..c5a33000a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractActivityChartFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractActivityChartFragment.java @@ -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 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 extend for (int i = 0; i < 6; i++) { entries.add(new ArrayList<>()); } - + boolean hr = supportsHeartrate(gbDevice); List heartrateEntries = hr ? new ArrayList(numEntries) : null; @@ -270,7 +271,7 @@ public abstract class AbstractActivityChartFragment extend lineData = new LineData(lineDataSets); - ValueFormatter xValueFormatter = new SampleXLabelFormatter(tsTranslation); + ValueFormatter xValueFormatter = new SampleXLabelFormatter(tsTranslation, "HH:mm"); return new DefaultChartsData<>(lineData, xValueFormatter); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityChartsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityChartsActivity.java index f8a04dbeb..fff96e34e 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityChartsActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityChartsActivity.java @@ -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": diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/LiveActivityFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/LiveActivityFragment.java index 07f3a1d01..53c5ec8c4 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/LiveActivityFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/LiveActivityFragment.java @@ -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 chartsData = new DefaultChartsData<>(lineData, xValueFormatter); return new Spo2ChartsData(chartsData, Math.round((float) averageSum / averageNumSamples)); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepsDailyFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepsDailyFragment.java index 85a709284..7bfaa5491 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepsDailyFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/StepsDailyFragment.java @@ -153,7 +153,7 @@ public class StepsDailyFragment extends StepsFragment chartsData = new DefaultChartsData<>(lineData, xValueFormatter); return new StressChartsData(pieData, chartsData, Math.round((float) averageSum / averageNumSamples), stressZoneTimes); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/VO2MaxFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/VO2MaxFragment.java new file mode 100644 index 000000000..eb9e06d85 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/VO2MaxFragment.java @@ -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 { + 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 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 samples = getAllSamples(db, device, tsFrom, tsEnd); + for (Vo2MaxSample sample : samples) { + records.add(new VO2MaxRecord(sample.getTimestamp() / 1000, sample.getValue(), sample.getType())); + } + Map 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 runningEntries = new ArrayList<>(); + List cyclingEntries = new ArrayList<>(); + List 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 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 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 getAllSamples(final DBHandler db, final GBDevice device, int tsFrom, int tsTo) { + final DeviceCoordinator coordinator = device.getDeviceCoordinator(); + final TimeSampleProvider 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 records; + private final Map latestValues; + + protected VO2MaxData(List records, Map latestValues) { + this.records = records; + this.latestValues = latestValues; + } + + @Nullable + public VO2MaxRecord getLatestValue(Vo2MaxSample.Type type) { + return this.latestValues.get(type); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractGaugeWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractGaugeWidget.java index e2a1e30b1..52a959c42 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractGaugeWidget.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractGaugeWidget.java @@ -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); } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardHrvWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardHrvWidget.java index b760e2964..2cbde24e9 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardHrvWidget.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardHrvWidget.java @@ -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; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/GaugeDrawer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/GaugeDrawer.java new file mode 100644 index 000000000..283ea678d --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/GaugeDrawer.java @@ -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; + } + +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java index 56967f889..8e7042abe 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java @@ -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; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java index 315da6094..8e81ccb72 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java @@ -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(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java index 4f909c2eb..ff472bdae 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java @@ -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; } diff --git a/app/src/main/res/layout/fragment_vo2max.xml b/app/src/main/res/layout/fragment_vo2max.xml new file mode 100644 index 000000000..30240b976 --- /dev/null +++ b/app/src/main/res/layout/fragment_vo2max.xml @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index fd3fd8a81..4086cb21d 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -3041,6 +3041,7 @@ @string/weekstepschart_steps_a_week_or_month @string/pref_header_hrv_status @string/body_energy + @string/vo2max @string/menuitem_stress @string/menuitem_pai @string/stats_title @@ -3057,6 +3058,7 @@ @string/p_steps_week @string/p_hrv_status @string/p_body_energy + @string/p_vo2max @string/p_stress @string/p_pai @string/p_speed_zones @@ -3073,6 +3075,7 @@ @string/p_sleep @string/p_hrv_status @string/p_body_energy + @string/p_vo2max @string/p_steps_week @string/p_stress @string/p_pai @@ -4174,6 +4177,7 @@ @string/active_time @string/menuitem_sleep @string/body_energy + @string/vo2max @string/menuitem_stress_simple @string/menuitem_stress_segmented @string/menuitem_stress_breakdown @@ -4188,6 +4192,7 @@ activetime sleep bodyenergy + vo2max stress_simple stress_segmented stress_breakdown diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 2486b734e..e791b7886 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -51,6 +51,14 @@ #fc5203 #be03fc #d12a2a + #46acea + #59b22c + #824be3 + #d93832 + #ffa703 + #04c79c + #02a8e6 + #824be3 #5ac234 #ff6c43 #00c9bf diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3a173e767..b2d8d010e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1623,6 +1623,9 @@ %1$d ms %1$d-%2$d ms Baseline + VO2Max Running + VO2Max Cycling + VO2Max General %1$d bpm %1$,.2f km Gained @@ -1990,6 +1993,9 @@ Warning! Note No data + VO2Max + Running VO2Max + Cycling VO2Max LED Color @@ -2530,6 +2536,8 @@ Blood Oxygen HRV Status Body Energy + VO2 Max + 30 Days Timeline Ambient Sound Control Sound Control Device Information diff --git a/app/src/main/res/values/values.xml b/app/src/main/res/values/values.xml index 18ffb9d4d..ee8e830e5 100644 --- a/app/src/main/res/values/values.xml +++ b/app/src/main/res/values/values.xml @@ -109,6 +109,7 @@ speedzones hrvstatus bodyenergy + vo2max livestats spo2 temperature