diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java index ddba06b93..9309e4b19 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java @@ -121,7 +121,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 = 25; + private static final int CURRENT_PREFS_VERSION = 26; private static LimitedQueue mIDSenderLookup = new LimitedQueue(16); private static Prefs prefs; @@ -1343,6 +1343,35 @@ public class GBApplication extends Application { } } + if (oldVersion < 26) { + 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 + ",spo2"; + } else { + newPrefValue = "spo2"; + } + + final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit(); + deviceSharedPrefsEdit.putString("charts_tabs", newPrefValue); + deviceSharedPrefsEdit.apply(); + } + } catch (Exception e) { + Log.w(TAG, "error acquiring DB lock"); + } + } + editor.putString(PREFS_VERSION, Integer.toString(CURRENT_PREFS_VERSION)); editor.apply(); } 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 a7e53393a..d4b702e5d 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 @@ -34,7 +34,6 @@ import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSett import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes; -import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper; import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; @@ -89,6 +88,9 @@ public class ActivityChartsActivity extends AbstractChartsActivity { if (!coordinator.supportsPai()) { tabList.remove("pai"); } + if (!coordinator.supportsSpo2()) { + tabList.remove("spo2"); + } return tabList; } @@ -128,6 +130,8 @@ public class ActivityChartsActivity extends AbstractChartsActivity { return new SpeedZonesFragment(); case "livestats": return new LiveActivityFragment(); + case "spo2": + return new Spo2ChartFragment(); } return null; } @@ -174,6 +178,8 @@ public class ActivityChartsActivity extends AbstractChartsActivity { return getString(R.string.stats_title); case "livestats": return getString(R.string.liveactivity_live_activity); + case "spo2": + return getString(R.string.pref_header_spo2); } return super.getPageTitle(position); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/Spo2ChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/Spo2ChartFragment.java new file mode 100644 index 000000000..f8e8be12b --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/Spo2ChartFragment.java @@ -0,0 +1,282 @@ +/* Copyright (C) 2023 José Rebelo, MartinJM + + 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 . */ +package nodomain.freeyourgadget.gadgetbridge.activities.charts; + +import android.graphics.Color; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.core.content.ContextCompat; + +import com.github.mikephil.charting.animation.Easing; +import com.github.mikephil.charting.charts.Chart; +import com.github.mikephil.charting.charts.LineChart; +import com.github.mikephil.charting.components.LegendEntry; +import com.github.mikephil.charting.components.LimitLine; +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.DefaultAxisValueFormatter; +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.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; +import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample; +import nodomain.freeyourgadget.gadgetbridge.util.Prefs; + +// Based on StressChartFragment + +public class Spo2ChartFragment extends AbstractChartFragment { + protected static final Logger LOG = LoggerFactory.getLogger(Spo2ChartFragment.class); + + private LineChart mSpo2Chart; + + private int BACKGROUND_COLOR; + private int DESCRIPTION_COLOR; + private int CHART_TEXT_COLOR; + private int LEGEND_TEXT_COLOR; + private int CHART_LINE_COLOR; + + private String SPO2_AVERAGE_LABEL; + + private final Prefs prefs = GBApplication.getPrefs(); + + private final boolean CHARTS_SLEEP_RANGE_24H = prefs.getBoolean("chart_sleep_range_24h", false); + private final boolean SHOW_CHARTS_AVERAGE = prefs.getBoolean("charts_show_average", true); + + @Override + protected void init() { + BACKGROUND_COLOR = GBApplication.getBackgroundColor(requireContext()); + LEGEND_TEXT_COLOR = DESCRIPTION_COLOR = GBApplication.getTextColor(requireContext()); + CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext()); + + if (prefs.getBoolean("chart_heartrate_color", false)) { + CHART_LINE_COLOR = ContextCompat.getColor(getContext(), R.color.chart_heartrate_alternative); + } else { + CHART_LINE_COLOR = ContextCompat.getColor(getContext(), R.color.chart_heartrate); + } + + SPO2_AVERAGE_LABEL = requireContext().getString(R.string.charts_legend_spo2_average); + } + + @Override + protected Spo2ChartsData refreshInBackground(final ChartsHost chartsHost, final DBHandler db, final GBDevice device) { + final List samples = getSamples(db, device); + + LOG.info("Got {} SpO2 samples", samples.size()); + + return new Spo2ChartsDataBuilder(samples).build(); + } + + protected LineDataSet createDataSet(final List values) { + final LineDataSet lineDataSet = new LineDataSet(values, "SpO2"); + lineDataSet.setColor(CHART_LINE_COLOR); + lineDataSet.setDrawCircles(false); + lineDataSet.setLineWidth(2.2f); + lineDataSet.setFillAlpha(255); + lineDataSet.setValueTextColor(CHART_TEXT_COLOR); + lineDataSet.setAxisDependency(YAxis.AxisDependency.LEFT); + lineDataSet.setValueFormatter(new ValueFormatter() { + @Override + public String getFormattedValue(float value) { + return String.format(Locale.ROOT, "%d", (int) value); + } + }); + return lineDataSet; + } + + @Override + protected void updateChartsnUIThread(final Spo2ChartsData spo2Data) { + final DefaultChartsData chartsData = spo2Data.getChartsData(); + mSpo2Chart.setData(null); // workaround for https://github.com/PhilJay/MPAndroidChart/issues/2317 + mSpo2Chart.getXAxis().setValueFormatter(chartsData.getXValueFormatter()); + mSpo2Chart.setData(chartsData.getData()); + mSpo2Chart.getAxisLeft().removeAllLimitLines(); + + LOG.info("SpO2 average: " + spo2Data.getAverage()); + + if (spo2Data.getAverage() > 0 && SHOW_CHARTS_AVERAGE) { + final LimitLine averageLine = new LimitLine(spo2Data.getAverage()); + averageLine.setLineColor(Color.RED); + averageLine.setLineWidth(0.1f); + mSpo2Chart.getAxisLeft().addLimitLine(averageLine); + } + + mSpo2Chart.getAxisRight().setEnabled(false); + } + + @Override + public String getTitle() { + return requireContext().getString(R.string.pref_header_spo2); + } + + @Override + public View onCreateView(final LayoutInflater inflater, + final ViewGroup container, + final Bundle savedInstanceState) { + final View rootView = inflater.inflate(R.layout.fragment_charts, container, false); + + mSpo2Chart = rootView.findViewById(R.id.activitysleepchart); + + setupLineChart(); + + // refresh immediately instead of use refreshIfVisible(), for perceived performance + refresh(); + + return rootView; + } + + private void setupLineChart() { + mSpo2Chart.setBackgroundColor(BACKGROUND_COLOR); + mSpo2Chart.getDescription().setTextColor(DESCRIPTION_COLOR); + configureBarLineChartDefaults(mSpo2Chart); + + final XAxis x = mSpo2Chart.getXAxis(); + x.setDrawLabels(true); + x.setDrawGridLines(false); + x.setEnabled(true); + x.setTextColor(CHART_TEXT_COLOR); + x.setDrawLimitLinesBehindData(true); + + final YAxis yAxisLeft = mSpo2Chart.getAxisLeft(); + yAxisLeft.setDrawGridLines(true); + yAxisLeft.setAxisMaximum(100f); + yAxisLeft.setAxisMinimum(75f); + yAxisLeft.setDrawTopYLabelEntry(false); + yAxisLeft.setTextColor(CHART_TEXT_COLOR); + yAxisLeft.setEnabled(true); + } + + @Override + protected void setupLegend(final Chart chart) { + final List legendEntries = new ArrayList<>(2); + + final LegendEntry entry = new LegendEntry(); + entry.label = requireContext().getString(R.string.pref_header_spo2); + entry.formColor = CHART_LINE_COLOR; + legendEntries.add(entry); + + if (SHOW_CHARTS_AVERAGE) { + final LegendEntry averageEntry = new LegendEntry(); + averageEntry.label = SPO2_AVERAGE_LABEL; + averageEntry.formColor = Color.RED; + legendEntries.add(averageEntry); + } + + chart.getLegend().setCustom(legendEntries); + chart.getLegend().setTextColor(LEGEND_TEXT_COLOR); + } + + @Override + protected void renderCharts() { + mSpo2Chart.animateX(ANIM_TIME, Easing.EaseInOutQuart); + } + + private List getSamples(final DBHandler db, final GBDevice device) { + final int tsStart = getTSStart(); + final int tsEnd = getTSEnd(); + final DeviceCoordinator coordinator = device.getDeviceCoordinator(); + final TimeSampleProvider sampleProvider = coordinator.getSpo2SampleProvider(device, db.getDaoSession()); + return sampleProvider.getAllSamples(tsStart * 1000L, tsEnd * 1000L); + } + + protected class Spo2ChartsDataBuilder { + private final List samples; + + private final TimestampTranslation tsTranslation = new TimestampTranslation(); + + private final List lineEntries = new ArrayList<>(); + + long averageSum; + long averageNumSamples; + + public Spo2ChartsDataBuilder(final List samples) { + this.samples = samples; + } + + private void reset() { + tsTranslation.reset(); + lineEntries.clear(); + + averageSum = 0; + averageNumSamples = 0; + } + + private void processSamples() { + reset(); + + for (final Spo2Sample sample : samples) { + processSample(sample); + } + } + + private void processSample(final Spo2Sample sample) { + final int ts = tsTranslation.shorten((int) (sample.getTimestamp() / 1000L)); + lineEntries.add(new Entry(ts, sample.getSpo2())); + + averageSum += sample.getSpo2(); + averageNumSamples += 1; + } + + public Spo2ChartsData build() { + processSamples(); + + final List lineDataSets = new ArrayList<>(); + + lineDataSets.add(createDataSet(lineEntries)); + + final LineData lineData = new LineData(lineDataSets); + final ValueFormatter xValueFormatter = new SampleXLabelFormatter(tsTranslation); + final DefaultChartsData chartsData = new DefaultChartsData<>(lineData, xValueFormatter); + return new Spo2ChartsData(chartsData, Math.round((float) averageSum / averageNumSamples)); + } + } + + protected static class Spo2ChartsData extends ChartsData { + private final DefaultChartsData chartsData; + private final int average; + + public Spo2ChartsData(final DefaultChartsData chartsData, final int average) { + this.chartsData = chartsData; + this.average = average; + } + + public DefaultChartsData getChartsData() { + return chartsData; + } + + public int getAverage() { + return average; + } + } +} diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 75b35d916..9f0cb3fcb 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -2685,6 +2685,7 @@ @string/menuitem_pai @string/stats_title @string/liveactivity_live_activity + @string/pref_header_spo2 @@ -2697,6 +2698,7 @@ @string/p_pai @string/p_speed_zones @string/p_live_stats + @string/p_spo2 @@ -2709,6 +2711,7 @@ @string/p_pai @string/p_speed_zones @string/p_live_stats + @string/p_spo2 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e184404e0..a137f4739 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1079,6 +1079,7 @@ Heart rate Heart rate average Stress average + Blood oxygen average Daily target: calories burnt Daily target: distance in meters Daily target: active time in minutes diff --git a/app/src/main/res/values/values.xml b/app/src/main/res/values/values.xml index 0ef16367a..3cddf520d 100644 --- a/app/src/main/res/values/values.xml +++ b/app/src/main/res/values/values.xml @@ -105,6 +105,7 @@ pai speedzones livestats + spo2 off complete