/* Copyright (C) 2017-2024 Andreas Shimokawa, Daniele Gobbetti, José Rebelo 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 static java.util.stream.Collectors.toCollection; import android.os.Build; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; 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.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.Comparator; import java.util.Date; import java.util.List; import java.util.Locale; import java.util.Optional; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardHrvWidget; 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.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.HrvSummarySample; import nodomain.freeyourgadget.gadgetbridge.model.HrvValueSample; import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; public class HRVStatusFragment extends AbstractChartFragment { protected static final Logger LOG = LoggerFactory.getLogger(HRVStatusFragment.class); protected final int TOTAL_DAYS = 7; protected GaugeDrawer gaugeDrawer; private ImageView mHRVStatusGauge; private LineChart mWeeklyHRVStatusChart; private TextView mHRVStatusSevenDaysAvg; private TextView mHRVStatusSevenDaysAvgStatus; // Balanced, Unbalanced, Low private TextView mHRVStatusLastNight; private TextView mHRVStatusLastNight5MinHighest; private TextView mHRVStatusDayAvg; private TextView mHRVStatusBaseline; private TextView mDateView; private TextView mHRVGaugeValue; private TextView mHRVGaugeStatus; 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_hrv_status, container, false); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { rootView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> { getChartsHost().enableSwipeRefresh(scrollY == 0); }); } mWeeklyHRVStatusChart = rootView.findViewById(R.id.hrv_weekly_line_chart); mHRVStatusLastNight = rootView.findViewById(R.id.hrv_status_last_night); mHRVStatusSevenDaysAvg = rootView.findViewById(R.id.hrv_status_seven_days_avg); mHRVStatusSevenDaysAvgStatus = rootView.findViewById(R.id.hrv_status_seven_days_avg_rate); mHRVStatusLastNight5MinHighest = rootView.findViewById(R.id.hrv_status_last_night_highest_5); mHRVStatusDayAvg = rootView.findViewById(R.id.hrv_status_day_avg); mHRVStatusBaseline = rootView.findViewById(R.id.hrv_status_baseline); mDateView = rootView.findViewById(R.id.hrv_status_date_view); mHRVStatusGauge = rootView.findViewById(R.id.hrv_status_gauge_bar); mHRVGaugeValue = rootView.findViewById(R.id.hrv_gauge_value); mHRVGaugeStatus = rootView.findViewById(R.id.hrv_gauge_status); gaugeDrawer = new GaugeDrawer(); setupLineChart(); refresh(); return rootView; } @Override public String getTitle() { return getString(R.string.pref_header_hrv_status); } @Override protected void init() { TEXT_COLOR = GBApplication.getTextColor(requireContext()); LEGEND_TEXT_COLOR = GBApplication.getTextColor(requireContext()); CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext()); } @Override protected HRVStatusWeeklyData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) { Calendar day = Calendar.getInstance(); day.setTime(getEndDate()); List weeklyData = getWeeklyData(db, day, device); return new HRVStatusWeeklyData(weeklyData); } @Override protected void renderCharts() { mWeeklyHRVStatusChart.invalidate(); } protected LineDataSet createDataSet(final List values) { final LineDataSet lineDataSet = new LineDataSet(values, getString(R.string.hrv_status_day_avg)); lineDataSet.setColor(getResources().getColor(R.color.hrv_status_char_line_color)); lineDataSet.setDrawCircles(false); lineDataSet.setLineWidth(2f); lineDataSet.setFillAlpha(255); lineDataSet.setCircleRadius(5f); lineDataSet.setDrawCircles(true); lineDataSet.setDrawCircleHole(true); lineDataSet.setCircleColor(getResources().getColor(R.color.hrv_status_char_line_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", (int) value); } }); return lineDataSet; } @Override protected void updateChartsnUIThread(HRVStatusWeeklyData weeklyData) { String formattedDate = new SimpleDateFormat("E, MMM dd").format(getEndDate()); mDateView.setText(formattedDate); mWeeklyHRVStatusChart.setData(null); // workaround for https://github.com/PhilJay/MPAndroidChart/issues/2317 List lineEntries = new ArrayList<>(); final List lineDataSets = new ArrayList<>(); weeklyData.getDaysData().forEach((HRVStatusDayData day) -> { if (day.status.getNum() > 0) { lineEntries.add(new Entry(day.i, day.dayAvg)); } else { if (!lineEntries.isEmpty()) { List clone = new ArrayList<>(lineEntries.size()); clone.addAll(lineEntries); lineDataSets.add(createDataSet(clone)); lineEntries.clear(); } } }); if (!lineEntries.isEmpty()) { lineDataSets.add(createDataSet(lineEntries)); } List legendEntries = new ArrayList<>(1); LegendEntry activityEntry = new LegendEntry(); activityEntry.label = getString(R.string.hrv_status_day_avg_legend); activityEntry.formColor = getResources().getColor(R.color.hrv_status_char_line_color); legendEntries.add(activityEntry); mWeeklyHRVStatusChart.getLegend().setTextColor(LEGEND_TEXT_COLOR); mWeeklyHRVStatusChart.getLegend().setCustom(legendEntries); final LineData lineData = new LineData(lineDataSets); mWeeklyHRVStatusChart.setData(lineData); final XAxis x = mWeeklyHRVStatusChart.getXAxis(); x.setValueFormatter(getHRVStatusChartDayValueFormatter(weeklyData)); HRVStatusDayData today = weeklyData.getCurrentDay(); mHRVStatusSevenDaysAvg.setText(today.weeklyAvg > 0 ? getString(R.string.hrv_status_unit, today.weeklyAvg) : "-"); mHRVStatusLastNight.setText(today.lastNight > 0 ? getString(R.string.hrv_status_unit, today.lastNight) : "-"); mHRVStatusLastNight5MinHighest.setText(today.lastNight5MinHigh > 0 ? getString(R.string.hrv_status_unit, today.lastNight5MinHigh) : "-"); mHRVStatusDayAvg.setText(today.dayAvg > 0 ? getString(R.string.hrv_status_unit, today.dayAvg) : "-"); mHRVStatusBaseline.setText(today.baseLineBalancedLower > 0 && today.baseLineBalancedUpper > 0 ? getString(R.string.hrv_status_baseline, today.baseLineBalancedLower, today.baseLineBalancedUpper) : "-"); switch (today.status) { case NONE: mHRVStatusSevenDaysAvgStatus.setText("-"); mHRVStatusSevenDaysAvgStatus.setTextColor(TEXT_COLOR); mHRVGaugeStatus.setText(""); mHRVGaugeStatus.setTextColor(TEXT_COLOR); break; case POOR: mHRVStatusSevenDaysAvgStatus.setText(getString(R.string.hrv_status_poor)); mHRVStatusSevenDaysAvgStatus.setTextColor(getResources().getColor(R.color.hrv_status_poor)); mHRVGaugeStatus.setText(getString(R.string.hrv_status_poor)); mHRVGaugeStatus.setTextColor(getResources().getColor(R.color.hrv_status_poor)); break; case LOW: mHRVStatusSevenDaysAvgStatus.setText(getString(R.string.hrv_status_low)); mHRVStatusSevenDaysAvgStatus.setTextColor(getResources().getColor(R.color.hrv_status_low)); mHRVGaugeStatus.setText(getString(R.string.hrv_status_low)); mHRVGaugeStatus.setTextColor(getResources().getColor(R.color.hrv_status_low)); break; case UNBALANCED: mHRVStatusSevenDaysAvgStatus.setText(getString(R.string.hrv_status_unbalanced)); mHRVStatusSevenDaysAvgStatus.setTextColor(getResources().getColor(R.color.hrv_status_unbalanced)); mHRVGaugeStatus.setText(getString(R.string.hrv_status_unbalanced)); mHRVGaugeStatus.setTextColor(getResources().getColor(R.color.hrv_status_unbalanced)); break; case BALANCED: mHRVStatusSevenDaysAvgStatus.setText(getString(R.string.hrv_status_balanced)); mHRVStatusSevenDaysAvgStatus.setTextColor(getResources().getColor(R.color.hrv_status_balanced)); mHRVGaugeStatus.setText(getString(R.string.hrv_status_balanced)); mHRVGaugeStatus.setTextColor(getResources().getColor(R.color.hrv_status_balanced)); break; } float value = DashboardHrvWidget.calculateGaugeValue(today.weeklyAvg, today.baseLineLowUpper, today.baseLineBalancedLower, today.baseLineBalancedUpper); final String valueText = value > 0 ? getString(R.string.hrv_status_unit, today.weeklyAvg) : getString(R.string.stats_empty_value); mHRVGaugeValue.setText(valueText); gaugeDrawer.drawSegmentedGauge(mHRVStatusGauge, DashboardHrvWidget.getColors(), DashboardHrvWidget.getSegments(), value, false, true); } private List getWeeklyData(DBHandler db, Calendar day, GBDevice device) { day = DateTimeUtils.dayStart(day); day.add(Calendar.DATE, -TOTAL_DAYS + 1); List weeklyData = new ArrayList<>(); for (int counter = 0; counter < TOTAL_DAYS; counter++) { int startTs = (int) (day.getTimeInMillis() / 1000); int endTs = startTs + 24 * 60 * 60 - 1; Optional latestSummarySample = getSamples(db, device, startTs, endTs) .stream() .max(Comparator.comparingLong(HrvSummarySample::getTimestamp)); List valueSamples = getHrvValueSamples(db, device, startTs, endTs); int avgHRV = (int) valueSamples.stream().mapToInt(HrvValueSample::getValue).average().orElse(0); if (latestSummarySample.isPresent()) { final HrvSummarySample sample = latestSummarySample.get(); Calendar finalDay = (Calendar) day.clone(); weeklyData.add(new HRVStatusDayData( finalDay, counter, sample.getTimestamp(), avgHRV, sample.getWeeklyAverage() != null ? sample.getWeeklyAverage() : 0, sample.getLastNightAverage() != null ? sample.getLastNightAverage() : 0, sample.getLastNight5MinHigh() != null ? sample.getLastNight5MinHigh() : 0, sample.getBaselineLowUpper() != null ? sample.getBaselineLowUpper() : 0, sample.getBaselineBalancedLower() != null ? sample.getBaselineBalancedLower() : 0, sample.getBaselineBalancedUpper() != null ? sample.getBaselineBalancedUpper() : 0, sample.getStatus() != null ? sample.getStatus() : HrvSummarySample.Status.NONE )); } else { HRVStatusDayData d = new HRVStatusDayData( (Calendar) day.clone(), counter, 0, avgHRV, 0, 0, 0, 0, 0, 0, HrvSummarySample.Status.NONE ); weeklyData.add(d); } day.add(Calendar.DATE, 1); } return weeklyData; } private List getSamples(final DBHandler db, final GBDevice device, int tsFrom, int tsTo) { final DeviceCoordinator coordinator = device.getDeviceCoordinator(); final TimeSampleProvider sampleProvider = coordinator.getHrvSummarySampleProvider(device, db.getDaoSession()); return sampleProvider.getAllSamples(tsFrom * 1000L, tsTo * 1000L); } public List getHrvValueSamples(final DBHandler db, final GBDevice device, int tsFrom, int tsTo) { final DeviceCoordinator coordinator = device.getDeviceCoordinator(); final TimeSampleProvider sampleProvider = coordinator.getHrvValueSampleProvider(device, db.getDaoSession()); return sampleProvider.getAllSamples(tsFrom * 1000L, tsTo * 1000L); } private void setupLineChart() { mWeeklyHRVStatusChart.getDescription().setEnabled(false); mWeeklyHRVStatusChart.setTouchEnabled(false); mWeeklyHRVStatusChart.setPinchZoom(false); mWeeklyHRVStatusChart.setDoubleTapToZoomEnabled(false); final XAxis xAxisBottom = mWeeklyHRVStatusChart.getXAxis(); xAxisBottom.setPosition(XAxis.XAxisPosition.BOTTOM); xAxisBottom.setDrawLabels(true); xAxisBottom.setDrawGridLines(false); xAxisBottom.setEnabled(true); xAxisBottom.setDrawLimitLinesBehindData(true); xAxisBottom.setAxisMaximum(6 + 0.5f); xAxisBottom.setAxisMinimum(0 - 0.5f); xAxisBottom.setTextColor(CHART_TEXT_COLOR); final YAxis yAxisLeft = mWeeklyHRVStatusChart.getAxisLeft(); yAxisLeft.setDrawGridLines(true); yAxisLeft.setAxisMaximum(120); yAxisLeft.setAxisMinimum(0); yAxisLeft.setDrawTopYLabelEntry(false); yAxisLeft.setEnabled(true); yAxisLeft.setTextColor(CHART_TEXT_COLOR); final YAxis yAxisRight = mWeeklyHRVStatusChart.getAxisRight(); yAxisRight.setEnabled(true); yAxisRight.setDrawLabels(false); yAxisRight.setDrawGridLines(false); yAxisRight.setDrawAxisLine(true); } ValueFormatter getHRVStatusChartDayValueFormatter(HRVStatusWeeklyData weeklyData) { return new ValueFormatter() { @Override public String getFormattedValue(float value) { return formatHRVStatusChartValue((long) value, weeklyData); } }; } protected String formatHRVStatusChartValue(long value, HRVStatusWeeklyData weeklyData) { HRVStatusDayData day = weeklyData.getDay((int) value); SimpleDateFormat formatLetterDay = new SimpleDateFormat("EEE", Locale.getDefault()); return formatLetterDay.format(new Date(day.day.getTimeInMillis())); } protected void setupLegend(Chart chart) {} protected static class HRVStatusWeeklyData extends ChartsData { private final List data; public HRVStatusWeeklyData(final List chartsData) { this.data = chartsData; } public HRVStatusDayData getDay(int i) { return this.data.get(i); } public HRVStatusDayData getCurrentDay() { return this.data.get(this.data.size() - 1); } public List getDaysData() { return data; } } protected static class HRVStatusDayData { public Integer i; public long timestamp; public Integer weeklyAvg; public Integer lastNight; public Integer lastNight5MinHigh; public Integer dayAvg; public Integer baseLineBalancedLower; public Integer baseLineBalancedUpper; public Integer baseLineLowUpper; public HrvSummarySample.Status status; public Calendar day; public HRVStatusDayData(Calendar day, int i, long timestamp, Integer dayAvg, Integer weeklyAvg, Integer lastNight, Integer lastNight5MinHigh, Integer baseLineLowUpper, Integer baseLineBalancedLower, Integer baseLineBalancedUpper, HrvSummarySample.Status status) { this.lastNight = lastNight; this.weeklyAvg = weeklyAvg; this.lastNight5MinHigh = lastNight5MinHigh; this.i = i; this.timestamp = timestamp; this.status = status; this.day = day; this.dayAvg = dayAvg; this.baseLineLowUpper = baseLineLowUpper; this.baseLineBalancedLower = baseLineBalancedLower; this.baseLineBalancedUpper = baseLineBalancedUpper; } } }