From 78ed1d2d1aaa82019694cc38b96c7ee7e4746601 Mon Sep 17 00:00:00 2001 From: trentsuzuki Date: Sun, 14 Jun 2026 13:41:52 -0400 Subject: [PATCH] Improve HRV status chart views --- .../charts/DefaultChartsProvider.kt | 2 +- .../activities/charts/HRVStatusFragment.java | 770 ++++++++++++++++-- .../charts/HrvStatusCollectionFragment.java | 51 ++ .../adapter/HrvStatusFragmentAdapter.java | 26 + .../main/res/layout/fragment_hrv_status.xml | 14 +- app/src/main/res/values/colors.xml | 6 +- app/src/main/res/values/strings.xml | 5 + 7 files changed, 802 insertions(+), 72 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/HrvStatusCollectionFragment.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/HrvStatusFragmentAdapter.java diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/DefaultChartsProvider.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/DefaultChartsProvider.kt index 262557d8d0..461120b5bd 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/DefaultChartsProvider.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/DefaultChartsProvider.kt @@ -133,7 +133,7 @@ open class DefaultChartsProvider : DeviceChartsProvider { "activitylist" -> ActivityListingChartFragment() "sleep" -> SleepCollectionFragment.newInstance(allowSwipe) "heartrate" -> HeartRateCollectionFragment.newInstance(allowSwipe) - "hrvstatus" -> HRVStatusFragment() + "hrvstatus" -> HrvStatusCollectionFragment.newInstance(allowSwipe) "bodyenergy" -> BodyEnergyCollectionFragment.newInstance(allowSwipe) "vo2max" -> VO2MaxFragment() "load" -> LoadFragment() diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/HRVStatusFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/HRVStatusFragment.java index 05b444d25c..66959397f0 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/HRVStatusFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/HRVStatusFragment.java @@ -16,23 +16,35 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.activities.charts; +import android.graphics.Paint; 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 android.widget.Toast; import com.github.mikephil.charting.charts.Chart; -import com.github.mikephil.charting.charts.LineChart; +import com.github.mikephil.charting.charts.CombinedChart; +import com.github.mikephil.charting.components.Legend; 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.BarData; +import com.github.mikephil.charting.data.BarDataSet; +import com.github.mikephil.charting.data.BarEntry; +import com.github.mikephil.charting.data.CandleData; +import com.github.mikephil.charting.data.CandleDataSet; +import com.github.mikephil.charting.data.CandleEntry; +import com.github.mikephil.charting.data.CombinedData; 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 com.google.android.material.chip.Chip; +import com.google.android.material.chip.ChipGroup; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -52,7 +64,9 @@ import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardHrvWid import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.GaugeDrawer; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.HrvSummarySample; import nodomain.freeyourgadget.gadgetbridge.model.HrvValueSample; @@ -62,15 +76,42 @@ 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; + private static final String ARG_VIEW_MODE = "view_mode"; + private static final String ARG_TOTAL_DAYS = "total_days"; + private static final int DEFAULT_TOTAL_DAYS = 7; + private static final float LEGEND_FORM_SIZE = 10f; + private static final float LEGEND_X_ENTRY_SPACE = 10f; + private static final float LEGEND_Y_ENTRY_SPACE = 4f; + private static final float LEGEND_FORM_TO_TEXT_SPACE = 5f; + + private enum ViewMode { + LAST_NIGHT, + PERIOD + } + + private enum PeriodDataType { + DAILY_AVERAGE, + LAST_NIGHT_AVERAGE, + DAILY_RANGE + } + + private static final PeriodDataType[] PERIOD_DATA_TYPE_ORDER = { + PeriodDataType.DAILY_AVERAGE, + PeriodDataType.LAST_NIGHT_AVERAGE, + PeriodDataType.DAILY_RANGE + }; + private static final PeriodDataType DEFAULT_PERIOD_DATA_TYPE = PeriodDataType.DAILY_AVERAGE; protected GaugeDrawer gaugeDrawer; private ImageView mHRVStatusGauge; - private LineChart mWeeklyHRVStatusChart; + private CombinedChart mWeeklyHRVStatusChart; + private ChipGroup mHRVStatusDataTypeGroup; private TextView mHRVStatusSevenDaysAvg; private TextView mHRVStatusSevenDaysAvgStatus; // Balanced, Unbalanced, Low private TextView mHRVStatusLastNight; + private TextView mHRVStatusLastNightLabel; private TextView mHRVStatusLastNight5MinHighest; + private TextView mHRVStatusLastNight5MinHighestLabel; private TextView mHRVStatusDayAvg; private TextView mHRVStatusBaseline; private TextView mDateView; @@ -79,6 +120,49 @@ public class HRVStatusFragment extends AbstractChartFragment { + if (checkedIds.isEmpty()) { + Toast.makeText(requireContext(), R.string.charts_at_least_one_item, Toast.LENGTH_SHORT).show(); + final Chip defaultChip = findPeriodDataTypeChip(DEFAULT_PERIOD_DATA_TYPE); + if (defaultChip != null) { + defaultChip.setChecked(true); + } + return; + } + + final boolean selectedDailyAverage = isPeriodDataTypeSelected(PeriodDataType.DAILY_AVERAGE); + final boolean selectedLastNightAverage = isPeriodDataTypeSelected(PeriodDataType.LAST_NIGHT_AVERAGE); + final boolean selectedDailyRange = isPeriodDataTypeSelected(PeriodDataType.DAILY_RANGE); + if (showDailyAverage == selectedDailyAverage + && showLastNightAverage == selectedLastNightAverage + && showDailyRange == selectedDailyRange) { + return; + } + + showDailyAverage = selectedDailyAverage; + showLastNightAverage = selectedLastNightAverage; + showDailyRange = selectedDailyRange; + refresh(); + }); + } + + private void addPeriodDataTypeChip(final LayoutInflater inflater, final PeriodDataType dataType) { + final Chip chip = (Chip) inflater.inflate(R.layout.layout_chart_chip, mHRVStatusDataTypeGroup, false); + chip.setId(View.generateViewId()); + chip.setText(getString(getPeriodDataTypeLabel(dataType))); + chip.setTag(dataType); + mHRVStatusDataTypeGroup.addView(chip); + chip.setChecked(isPeriodDataTypeVisible(dataType)); + } + + private boolean isPeriodDataTypeSelected(final PeriodDataType dataType) { + final Chip chip = findPeriodDataTypeChip(dataType); + return chip != null && chip.isChecked(); + } + + private boolean isPeriodDataTypeVisible(final PeriodDataType dataType) { + switch (dataType) { + case DAILY_AVERAGE: + return showDailyAverage; + case LAST_NIGHT_AVERAGE: + return showLastNightAverage; + case DAILY_RANGE: + return showDailyRange; + default: + throw new IllegalArgumentException("Unknown period data type: " + dataType); + } + } + + private Chip findPeriodDataTypeChip(final PeriodDataType dataType) { + for (int i = 0; i < mHRVStatusDataTypeGroup.getChildCount(); i++) { + final View child = mHRVStatusDataTypeGroup.getChildAt(i); + if (child instanceof Chip && child.getTag() == dataType) { + return (Chip) child; + } + } + return null; + } + + private int getPeriodDataTypeLabel(final PeriodDataType dataType) { + switch (dataType) { + case DAILY_AVERAGE: + return R.string.hrv_status_day_avg_legend; + case LAST_NIGHT_AVERAGE: + return R.string.hrv_status_nightly_avg_legend; + case DAILY_RANGE: + return R.string.hrv_status_daily_range_legend; + default: + throw new IllegalArgumentException("Unknown period data type: " + dataType); + } + } + @Override public String getTitle() { return getString(R.string.pref_header_hrv_status); @@ -117,6 +299,10 @@ public class HRVStatusFragment extends AbstractChartFragment weeklyData = getWeeklyData(db, day, device); - return new HRVStatusWeeklyData(weeklyData); + final List periodData = getPeriodData(db, day, device); + final LastNightData lastNightData = viewMode == ViewMode.LAST_NIGHT ? + getLastNightData(db, device, day) : + new LastNightData(new ArrayList<>(), getLastNightSearchStart(day), getLastNightSearchEnd(day)); + return new HRVStatusWeeklyData(periodData, lastNightData.samples, lastNightData.start, lastNightData.end); } @Override @@ -133,18 +322,17 @@ public class HRVStatusFragment extends AbstractChartFragment 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); + protected LineDataSet createAverageDataSet(final List values) { + final LineDataSet lineDataSet = new LineDataSet(values, getString(R.string.hrv_status_day_avg_legend)); + lineDataSet.setColor(HRV_AVERAGE_COLOR); + lineDataSet.setLineWidth(2.5f); lineDataSet.setFillAlpha(255); lineDataSet.setCircleRadius(5f); lineDataSet.setDrawCircles(true); lineDataSet.setDrawCircleHole(false); - lineDataSet.setCircleColor(getResources().getColor(R.color.hrv_status_char_line_color)); + lineDataSet.setCircleColor(HRV_AVERAGE_COLOR); lineDataSet.setAxisDependency(YAxis.AxisDependency.LEFT); - lineDataSet.setDrawValues(true); + lineDataSet.setDrawValues(false); lineDataSet.setValueTextSize(10f); lineDataSet.setValueTextColor(TEXT_COLOR); lineDataSet.setValueFormatter(new ValueFormatter() { @@ -156,61 +344,75 @@ public class HRVStatusFragment extends AbstractChartFragment values) { + final BarDataSet barDataSet = new BarDataSet(values, getString(R.string.hrv_status_baseline_label)); + barDataSet.setDrawValues(false); + barDataSet.setDrawIcons(false); + barDataSet.setAxisDependency(YAxis.AxisDependency.LEFT); + barDataSet.setColors(0x00000000, HRV_BASELINE_FILL_COLOR); + barDataSet.setHighLightAlpha(0); + barDataSet.setHighlightEnabled(false); + return barDataSet; + } + + protected CandleDataSet createRangeDataSet(final List values) { + final CandleDataSet candleDataSet = new CandleDataSet(values, getString(R.string.hrv_status_daily_range_legend)); + candleDataSet.setDrawValues(false); + candleDataSet.setDrawIcons(false); + candleDataSet.setAxisDependency(YAxis.AxisDependency.LEFT); + candleDataSet.setShadowColor(HRV_RANGE_COLOR); + candleDataSet.setShadowWidth(2f); + candleDataSet.setDecreasingColor(HRV_RANGE_COLOR); + candleDataSet.setDecreasingPaintStyle(Paint.Style.FILL); + candleDataSet.setIncreasingColor(HRV_RANGE_COLOR); + candleDataSet.setIncreasingPaintStyle(Paint.Style.FILL); + candleDataSet.setNeutralColor(HRV_RANGE_COLOR); + candleDataSet.setBarSpace(0.35f); + candleDataSet.setShowCandleBar(true); + candleDataSet.setHighlightEnabled(false); + return candleDataSet; + } + + protected LineDataSet createLastNightAverageDataSet(final List values) { + final LineDataSet lineDataSet = new LineDataSet(values, getString(R.string.hrv_status_nightly_avg_legend)); + lineDataSet.setColor(HRV_LAST_NIGHT_COLOR); + lineDataSet.setLineWidth(2f); + lineDataSet.setCircleRadius(4f); + lineDataSet.setDrawCircles(true); + lineDataSet.setDrawCircleHole(false); + lineDataSet.setCircleColor(HRV_LAST_NIGHT_COLOR); + lineDataSet.setAxisDependency(YAxis.AxisDependency.LEFT); + lineDataSet.setDrawValues(false); + lineDataSet.setHighlightEnabled(false); + return lineDataSet; + } + + protected LineDataSet createRawHrvDataSet(final List values) { + final LineDataSet lineDataSet = new LineDataSet(values, getString(R.string.hrv_status_last_night_legend)); + lineDataSet.setColor(HRV_AVERAGE_COLOR); + lineDataSet.setDrawCircles(false); + lineDataSet.setLineWidth(2f); + lineDataSet.setAxisDependency(YAxis.AxisDependency.LEFT); + lineDataSet.setDrawValues(false); + lineDataSet.setHighlightEnabled(false); + 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<>(); - final Accumulator dailyAccumulator = new Accumulator(); - weeklyData.getDaysData().forEach((HRVStatusDayData day) -> { - if (day.dayAvg > 0) { - dailyAccumulator.add(day.dayAvg); - 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); - - if (dailyAccumulator.getCount() > 0) { - mWeeklyHRVStatusChart.getAxisLeft().setAxisMaximum(Math.round(dailyAccumulator.getMax()) + 15); - mWeeklyHRVStatusChart.getAxisLeft().setAxisMinimum(Math.max(0, Math.round(dailyAccumulator.getMin()) - 15)); + if (viewMode == ViewMode.LAST_NIGHT) { + updateLastNightChart(weeklyData); } else { - mWeeklyHRVStatusChart.getAxisLeft().setAxisMaximum(120); - mWeeklyHRVStatusChart.getAxisLeft().setAxisMinimum(0); + updatePeriodChart(weeklyData); } - final XAxis x = mWeeklyHRVStatusChart.getXAxis(); - x.setValueFormatter(getHRVStatusChartDayValueFormatter(weeklyData)); - HRVStatusDayData today = weeklyData.getCurrentDay(); // Show weekly average even if we don't have full 7 days - it's computed from available data mHRVStatusSevenDaysAvg.setText(today.weeklyAvg > 0 ? getString(R.string.hrv_status_unit, today.weeklyAvg) : - (today.dayAvg > 0 ? getString(R.string.hrv_status_unit, today.dayAvg) : "-")); - 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) : "-"); + (today.dayAvg > 0 ? getString(R.string.hrv_status_unit, today.dayAvg) : getString(R.string.stats_empty_value))); + updateNightlySummaryStats(weeklyData, today); + mHRVStatusDayAvg.setText(formatHrvStatusValue(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: @@ -250,12 +452,304 @@ public class HRVStatusFragment extends AbstractChartFragment getWeeklyData(DBHandler db, Calendar day, GBDevice device) { + private void updateNightlySummaryStats(final HRVStatusWeeklyData weeklyData, final HRVStatusDayData today) { + updateNightlySummaryLabels(); + if (viewMode == ViewMode.LAST_NIGHT) { + mHRVStatusLastNight.setText(formatHrvStatusValue(today.lastNight)); + mHRVStatusLastNight5MinHighest.setText(formatHrvStatusValue(today.lastNight5MinHigh)); + return; + } + + mHRVStatusLastNight.setText(formatHrvStatusValue(weeklyData.getPeriodNightlyAverage())); + mHRVStatusLastNight5MinHighest.setText(formatHrvStatusValue(weeklyData.getHighestNightlyAverage())); + } + + private void updateNightlySummaryLabels() { + if (viewMode == ViewMode.LAST_NIGHT) { + mHRVStatusLastNightLabel.setText(R.string.hrv_status_last_night); + mHRVStatusLastNight5MinHighestLabel.setText(R.string.hrv_status_last_night_highest_5); + return; + } + + mHRVStatusLastNightLabel.setText(R.string.hrv_status_nightly_avg); + mHRVStatusLastNight5MinHighestLabel.setText(R.string.hrv_status_highest_nightly_avg); + } + + private String formatHrvStatusValue(final int value) { + return value > 0 ? getString(R.string.hrv_status_unit, value) : getString(R.string.stats_empty_value); + } + + private void updatePeriodChart(HRVStatusWeeklyData weeklyData) { + mDateView.setText(DateTimeUtils.formatDaysUntil(totalDays, getTSEnd())); + final List rangeEntries = new ArrayList<>(); + final List averageEntries = new ArrayList<>(); + final List lastNightEntries = new ArrayList<>(); + final List baselineEntries = new ArrayList<>(); + final List lineDataSets = new ArrayList<>(); + final List lastNightDataSets = new ArrayList<>(); + final Accumulator axisAccumulator = new Accumulator(); + boolean hasAverageData = false; + boolean hasLastNightData = false; + + for (final HRVStatusDayData day : weeklyData.getDaysData()) { + if (showDailyAverage) { + if (day.dayAvg > 0) { + hasAverageData = true; + axisAccumulator.add(day.dayAvg); + averageEntries.add(new Entry(day.i, day.dayAvg)); + } else { + addAverageDataSet(lineDataSets, averageEntries); + } + } + + if (showLastNightAverage) { + if (day.lastNight > 0) { + hasLastNightData = true; + axisAccumulator.add(day.lastNight); + lastNightEntries.add(new Entry(day.i, day.lastNight)); + } else { + addLastNightAverageDataSet(lastNightDataSets, lastNightEntries); + } + } + + if (showDailyRange && day.dayMin > 0 && day.dayMax > 0) { + axisAccumulator.add(day.dayMin); + axisAccumulator.add(day.dayMax); + rangeEntries.add(new CandleEntry(day.i, day.dayMax, day.dayMin, day.dayMin, day.dayMax)); + } + + if (day.baseLineBalancedLower > 0 && day.baseLineBalancedUpper > day.baseLineBalancedLower) { + axisAccumulator.add(day.baseLineBalancedLower); + axisAccumulator.add(day.baseLineBalancedUpper); + baselineEntries.add(new BarEntry(day.i, new float[]{ + day.baseLineBalancedLower, + day.baseLineBalancedUpper - day.baseLineBalancedLower + })); + } + } + + if (showDailyAverage) { + addAverageDataSet(lineDataSets, averageEntries); + } + if (showLastNightAverage) { + addLastNightAverageDataSet(lastNightDataSets, lastNightEntries); + } + lineDataSets.addAll(lastNightDataSets); + + final boolean hasChartData = !rangeEntries.isEmpty() || !lineDataSets.isEmpty() || !baselineEntries.isEmpty(); + if (hasChartData) { + final CombinedData combinedData = new CombinedData(); + if (!baselineEntries.isEmpty()) { + final BarData barData = new BarData(createBaselineRangeDataSet(baselineEntries)); + barData.setBarWidth(1f); + combinedData.setData(barData); + } + if (!rangeEntries.isEmpty()) { + combinedData.setData(new CandleData(createRangeDataSet(rangeEntries))); + } + if (!lineDataSets.isEmpty()) { + combinedData.setData(new LineData(lineDataSets)); + } + mWeeklyHRVStatusChart.setData(combinedData); + } else { + mWeeklyHRVStatusChart.setData(null); + } + if (axisAccumulator.getCount() > 0) { + mWeeklyHRVStatusChart.getAxisLeft().setAxisMaximum(Math.round(axisAccumulator.getMax()) + 15); + mWeeklyHRVStatusChart.getAxisLeft().setAxisMinimum(Math.max(0, Math.round(axisAccumulator.getMin()) - 15)); + } else { + mWeeklyHRVStatusChart.getAxisLeft().setAxisMaximum(120); + mWeeklyHRVStatusChart.getAxisLeft().setAxisMinimum(0); + } + + final XAxis x = mWeeklyHRVStatusChart.getXAxis(); + x.setValueFormatter(getHRVStatusChartDayValueFormatter(weeklyData)); + x.setAxisMinimum(0f); + x.setAxisMaximum(totalDays - 1f); + x.setLabelCount(Math.min(totalDays, 7), false); + setupPeriodLegend(!rangeEntries.isEmpty(), hasAverageData, hasLastNightData, !baselineEntries.isEmpty()); + } + + private void updateLastNightChart(HRVStatusWeeklyData weeklyData) { + final String formattedDate = new SimpleDateFormat("E, MMM dd", Locale.getDefault()).format(getEndDate()); + mDateView.setText(formattedDate); + + final List rawEntries = new ArrayList<>(); + final Accumulator axisAccumulator = new Accumulator(); + final long startMillis = weeklyData.lastNightStart.getTimeInMillis(); + float minX = Float.MAX_VALUE; + float maxX = -Float.MAX_VALUE; + for (HrvValueSample sample : weeklyData.lastNightSamples) { + if (sample.getValue() <= 0) { + continue; + } + + final float x = (sample.getTimestamp() - startMillis) / 1000f; + rawEntries.add(new Entry(x, sample.getValue())); + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + axisAccumulator.add(sample.getValue()); + } + rawEntries.sort(Comparator.comparingDouble(Entry::getX)); + + if (!rawEntries.isEmpty()) { + final CombinedData combinedData = new CombinedData(); + combinedData.setData(new LineData(createRawHrvDataSet(rawEntries))); + mWeeklyHRVStatusChart.setData(combinedData); + } else { + mWeeklyHRVStatusChart.setData(null); + } + + if (axisAccumulator.getCount() > 0) { + mWeeklyHRVStatusChart.getAxisLeft().setAxisMaximum(Math.round(axisAccumulator.getMax()) + 15); + mWeeklyHRVStatusChart.getAxisLeft().setAxisMinimum(Math.max(0, Math.round(axisAccumulator.getMin()) - 15)); + } else { + mWeeklyHRVStatusChart.getAxisLeft().setAxisMaximum(120); + mWeeklyHRVStatusChart.getAxisLeft().setAxisMinimum(0); + } + + final XAxis x = mWeeklyHRVStatusChart.getXAxis(); + x.setValueFormatter(getLastNightChartValueFormatter(weeklyData.lastNightStart)); + if (rawEntries.isEmpty()) { + x.setAxisMinimum(0f); + x.setAxisMaximum((weeklyData.lastNightEnd.getTimeInMillis() - weeklyData.lastNightStart.getTimeInMillis()) / 1000f); + } else if (minX == maxX) { + x.setAxisMinimum(minX - 1f); + x.setAxisMaximum(maxX + 1f); + } else { + x.setAxisMinimum(minX); + x.setAxisMaximum(maxX); + } + x.setLabelCount(7, true); + setupLastNightLegend(!rawEntries.isEmpty()); + } + + private void addAverageDataSet(final List lineDataSets, final List averageEntries) { + if (averageEntries.isEmpty()) { + return; + } + + lineDataSets.add(createAverageDataSet(new ArrayList<>(averageEntries))); + averageEntries.clear(); + } + + private void addLastNightAverageDataSet(final List lineDataSets, final List lastNightEntries) { + if (lastNightEntries.isEmpty()) { + return; + } + + lineDataSets.add(createLastNightAverageDataSet(new ArrayList<>(lastNightEntries))); + lastNightEntries.clear(); + } + + private void setupPeriodLegend(final boolean hasRange, + final boolean hasDailyAverage, + final boolean hasLastNightAverage, + final boolean hasBaseline) { + final List legendEntries = new ArrayList<>(4); + + for (final PeriodDataType dataType : PERIOD_DATA_TYPE_ORDER) { + if (hasPeriodDataTypeData(dataType, hasRange, hasDailyAverage, hasLastNightAverage)) { + legendEntries.add(createPeriodDataTypeLegendEntry(dataType)); + } + } + + if (hasBaseline) { + legendEntries.add(createSquareLegendEntry( + getString(R.string.hrv_status_baseline_label), + HRV_BASELINE_FILL_COLOR)); + } + + setupHRVStatusLegend(legendEntries); + } + + private void setupLastNightLegend(final boolean hasRawHrv) { + final List legendEntries = new ArrayList<>(1); + if (hasRawHrv) { + legendEntries.add(createLineLegendEntry(getString(R.string.hrv_status_last_night_legend), HRV_AVERAGE_COLOR)); + } + + setupHRVStatusLegend(legendEntries); + } + + private boolean hasPeriodDataTypeData(final PeriodDataType dataType, + final boolean hasRange, + final boolean hasDailyAverage, + final boolean hasLastNightAverage) { + switch (dataType) { + case DAILY_AVERAGE: + return hasDailyAverage; + case LAST_NIGHT_AVERAGE: + return hasLastNightAverage; + case DAILY_RANGE: + return hasRange; + default: + throw new IllegalArgumentException("Unknown period data type: " + dataType); + } + } + + private LegendEntry createPeriodDataTypeLegendEntry(final PeriodDataType dataType) { + switch (dataType) { + case DAILY_AVERAGE: + return createLineLegendEntry(getString(getPeriodDataTypeLabel(dataType)), HRV_AVERAGE_COLOR); + case LAST_NIGHT_AVERAGE: + return createLineLegendEntry(getString(getPeriodDataTypeLabel(dataType)), HRV_LAST_NIGHT_COLOR); + case DAILY_RANGE: + return createSquareLegendEntry(getString(getPeriodDataTypeLabel(dataType)), HRV_RANGE_COLOR); + default: + throw new IllegalArgumentException("Unknown period data type: " + dataType); + } + } + + private LegendEntry createLineLegendEntry(final String label, final int color) { + final LegendEntry legendEntry = new LegendEntry(); + legendEntry.label = label; + legendEntry.form = Legend.LegendForm.LINE; + legendEntry.formSize = LEGEND_FORM_SIZE; + legendEntry.formLineWidth = 2f; + legendEntry.formColor = color; + return legendEntry; + } + + private LegendEntry createSquareLegendEntry(final String label, final int color) { + final LegendEntry legendEntry = new LegendEntry(); + legendEntry.label = label; + legendEntry.form = Legend.LegendForm.SQUARE; + legendEntry.formSize = LEGEND_FORM_SIZE; + legendEntry.formColor = color; + return legendEntry; + } + + private void setupHRVStatusLegend(final List legendEntries) { + final Legend legend = mWeeklyHRVStatusChart.getLegend(); + legend.resetCustom(); + if (legendEntries.isEmpty()) { + legend.setEnabled(false); + mWeeklyHRVStatusChart.notifyDataSetChanged(); + mWeeklyHRVStatusChart.requestLayout(); + return; + } + + legend.setEnabled(true); + legend.setTextColor(LEGEND_TEXT_COLOR); + legend.setWordWrapEnabled(true); + legend.setHorizontalAlignment(Legend.LegendHorizontalAlignment.LEFT); + legend.setOrientation(Legend.LegendOrientation.HORIZONTAL); + legend.setDrawInside(false); + legend.setXEntrySpace(LEGEND_X_ENTRY_SPACE); + legend.setYEntrySpace(LEGEND_Y_ENTRY_SPACE); + legend.setFormToTextSpace(LEGEND_FORM_TO_TEXT_SPACE); + legend.setCustom(legendEntries); + mWeeklyHRVStatusChart.notifyDataSetChanged(); + mWeeklyHRVStatusChart.requestLayout(); + } + + private List getPeriodData(DBHandler db, Calendar day, GBDevice device) { day = DateTimeUtils.dayStart(day); - day.add(Calendar.DATE, -TOTAL_DAYS + 1); + day.add(Calendar.DATE, -totalDays + 1); List weeklyData = new ArrayList<>(); - for (int counter = 0; counter < TOTAL_DAYS; counter++) { + for (int counter = 0; counter < totalDays; counter++) { int startTs = (int) (day.getTimeInMillis() / 1000); int endTs = startTs + 24 * 60 * 60 - 1; Optional latestSummarySample = getSamples(db, device, startTs, endTs) @@ -263,7 +757,16 @@ public class HRVStatusFragment extends AbstractChartFragment valueSamples = getHrvValueSamples(db, device, startTs, endTs); - int avgHRV = (int) valueSamples.stream().mapToInt(HrvValueSample::getValue).average().orElse(0); + final Accumulator dayAccumulator = new Accumulator(); + for (HrvValueSample valueSample : valueSamples) { + if (valueSample.getValue() > 0) { + dayAccumulator.add(valueSample.getValue()); + } + } + + int avgHRV = dayAccumulator.getCount() > 0 ? (int) Math.round(dayAccumulator.getAverage()) : 0; + int minHRV = dayAccumulator.getCount() > 0 ? (int) Math.round(dayAccumulator.getMin()) : 0; + int maxHRV = dayAccumulator.getCount() > 0 ? (int) Math.round(dayAccumulator.getMax()) : 0; if (latestSummarySample.isPresent()) { final HrvSummarySample sample = latestSummarySample.get(); Calendar finalDay = (Calendar) day.clone(); @@ -272,6 +775,8 @@ public class HRVStatusFragment extends AbstractChartFragment sampleProvider = + device.getDeviceCoordinator().getSampleProvider(device, db.getDaoSession()); + final List activitySamples = + sampleProvider.getAllActivitySamples(startTs, endTs); + + if (activitySamples.isEmpty()) { + return new LastNightData(new ArrayList<>(), searchStart, searchEnd); + } + + final SleepAnalysis sleepAnalysis = new SleepAnalysis(); + final List sleepSessions = + sleepAnalysis.calculateSleepSessions(activitySamples); + if (sleepSessions.isEmpty()) { + return new LastNightData(new ArrayList<>(), searchStart, searchEnd); + } + + final SleepAnalysis.SleepSession lastSession = sleepSessions.get(sleepSessions.size() - 1); + final Calendar sleepStart = Calendar.getInstance(); + sleepStart.setTime(lastSession.getSleepStart()); + final Calendar sleepEnd = Calendar.getInstance(); + sleepEnd.setTime(lastSession.getSleepEnd()); + + final List samples = getHrvValueSamples( + db, + device, + (int) (sleepStart.getTimeInMillis() / 1000), + (int) (sleepEnd.getTimeInMillis() / 1000)); + return new LastNightData(samples, sleepStart, sleepEnd); + } + + private Calendar getLastNightSearchStart(final Calendar day) { + final Calendar lastNightStart = DateTimeUtils.dayStart((Calendar) day.clone()); + lastNightStart.add(Calendar.DATE, -1); + lastNightStart.add(Calendar.HOUR_OF_DAY, 12); + return lastNightStart; + } + + private Calendar getLastNightSearchEnd(final Calendar day) { + final Calendar lastNightEnd = DateTimeUtils.dayStart((Calendar) day.clone()); + lastNightEnd.add(Calendar.HOUR_OF_DAY, 12); + return lastNightEnd; + } + private void setupLineChart() { mWeeklyHRVStatusChart.getDescription().setEnabled(false); + mWeeklyHRVStatusChart.setDrawOrder(new CombinedChart.DrawOrder[]{ + CombinedChart.DrawOrder.BAR, + CombinedChart.DrawOrder.CANDLE, + CombinedChart.DrawOrder.LINE + }); mWeeklyHRVStatusChart.setTouchEnabled(false); mWeeklyHRVStatusChart.setPinchZoom(false); mWeeklyHRVStatusChart.setDoubleTapToZoomEnabled(false); @@ -335,8 +896,10 @@ public class HRVStatusFragment extends AbstractChartFragment= weeklyData.getDaysData().size()) { + return ""; + } - SimpleDateFormat formatLetterDay = new SimpleDateFormat("EEE", Locale.getDefault()); + HRVStatusDayData day = weeklyData.getDay(dayIndex); + + final String formatPattern = totalDays == DEFAULT_TOTAL_DAYS ? "EEE" : "dd"; + SimpleDateFormat formatLetterDay = new SimpleDateFormat(formatPattern, Locale.getDefault()); return formatLetterDay.format(new Date(day.day.getTimeInMillis())); } + ValueFormatter getLastNightChartValueFormatter(final Calendar lastNightStart) { + return new ValueFormatter() { + @Override + public String getFormattedValue(float value) { + return formatLastNightChartValue(value, lastNightStart); + } + }; + } + + protected String formatLastNightChartValue(final float value, final Calendar lastNightStart) { + final long timestamp = lastNightStart.getTimeInMillis() + (long) (value * 1000); + return new SimpleDateFormat("HH:mm", Locale.getDefault()).format(new Date(timestamp)); + } + @Override protected void setupLegend(Chart chart) {} protected static class HRVStatusWeeklyData extends ChartsData { private final List data; + private final List lastNightSamples; + private final Calendar lastNightStart; + private final Calendar lastNightEnd; - public HRVStatusWeeklyData(final List chartsData) { + public HRVStatusWeeklyData(final List chartsData, + final List lastNightSamples, + final Calendar lastNightStart, + final Calendar lastNightEnd) { this.data = chartsData; + this.lastNightSamples = lastNightSamples; + this.lastNightStart = lastNightStart; + this.lastNightEnd = lastNightEnd; } public HRVStatusDayData getDay(int i) { @@ -391,6 +983,40 @@ public class HRVStatusFragment extends AbstractChartFragment getDaysData() { return data; } + + public int getPeriodNightlyAverage() { + final Accumulator accumulator = new Accumulator(); + for (final HRVStatusDayData day : data) { + if (day.lastNight > 0) { + accumulator.add(day.lastNight); + } + } + return accumulator.getCount() > 0 ? (int) Math.round(accumulator.getAverage()) : 0; + } + + public int getHighestNightlyAverage() { + int highestNightlyAverage = 0; + for (final HRVStatusDayData day : data) { + if (day.lastNight > highestNightlyAverage) { + highestNightlyAverage = day.lastNight; + } + } + return highestNightlyAverage; + } + } + + protected static class LastNightData { + private final List samples; + private final Calendar start; + private final Calendar end; + + public LastNightData(final List samples, + final Calendar start, + final Calendar end) { + this.samples = samples; + this.start = start; + this.end = end; + } } protected static class HRVStatusDayData { @@ -400,6 +1026,8 @@ public class HRVStatusFragment extends AbstractChartFragment { + switch (position) { + case 0: + tab.setText(getString(R.string.calendar_day)); + break; + case 1: + tab.setText(getString(R.string.calendar_week)); + break; + case 2: + tab.setText(getString(R.string.calendar_month)); + break; + } + }).attach(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/HrvStatusFragmentAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/HrvStatusFragmentAdapter.java new file mode 100644 index 0000000000..d3c0a16622 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/HrvStatusFragmentAdapter.java @@ -0,0 +1,26 @@ +package nodomain.freeyourgadget.gadgetbridge.adapter; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; + +import nodomain.freeyourgadget.gadgetbridge.activities.charts.HRVStatusFragment; + +public class HrvStatusFragmentAdapter extends NestedFragmentAdapter { + public HrvStatusFragmentAdapter(final Fragment fragment) { + super(fragment); + } + + @NonNull + @Override + public Fragment createFragment(final int position) { + switch (position) { + case 0: + return HRVStatusFragment.newLastNightInstance(); + case 1: + return HRVStatusFragment.newPeriodInstance(7); + case 2: + return HRVStatusFragment.newPeriodInstance(30); + } + return HRVStatusFragment.newPeriodInstance(7); + } +} diff --git a/app/src/main/res/layout/fragment_hrv_status.xml b/app/src/main/res/layout/fragment_hrv_status.xml index 8c09f0bdb8..cd4613df93 100644 --- a/app/src/main/res/layout/fragment_hrv_status.xml +++ b/app/src/main/res/layout/fragment_hrv_status.xml @@ -1,4 +1,5 @@ - + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index e5b422164b..b247adfda1 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -57,6 +57,10 @@ #fc5203 #be03fc #d12a2a + #99d12a2a + #3f7dff + #0c8a03 + #660c8a03 #FFF #1d2436 #8847a1 @@ -120,4 +124,4 @@ @color/accent - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2f167ddcd9..589f908d6b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1842,11 +1842,16 @@ Low Poor Last night + Last night (ms) Last night 5-min max avg + Nightly average + Nightly average (ms) + Highest nightly avg 7-day average %1$d ms %1$d – %2$d ms Baseline + Daily range Acute Load Chronic Load This week total