/* Copyright (C) 2023-2024 Martin.JM 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; } } }