/* Copyright (C) 2023-2024 Daniel Dakhno, 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 android.content.Context; import android.graphics.Color; import android.os.Build; import android.os.Bundle; import android.text.Spannable; import android.text.SpannableString; import android.text.Spanned; import android.text.style.RelativeSizeSpan; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; 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.charts.PieChart; 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.data.PieData; import com.github.mikephil.charting.data.PieDataSet; import com.github.mikephil.charting.data.PieEntry; 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.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; 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.StressSample; import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; public class StressChartFragment extends AbstractChartFragment { protected static final Logger LOG = LoggerFactory.getLogger(ActivitySleepChartFragment.class); private LineChart mStressChart; private PieChart mStressLevelsPieChart; private TextView stressChartRelaxedTime; private TextView stressChartMildTime; private TextView stressChartModerateTime; private TextView stressChartHighTime; private TextView stressDate; private int BACKGROUND_COLOR; private int DESCRIPTION_COLOR; private int CHART_TEXT_COLOR; private int LEGEND_TEXT_COLOR; private String STRESS_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()); STRESS_AVERAGE_LABEL = requireContext().getString(R.string.charts_legend_stress_average); } @Override protected StressChartsData refreshInBackground(final ChartsHost chartsHost, final DBHandler db, final GBDevice device) { int tsEnd = getTSEnd(); Calendar day = Calendar.getInstance(); day.setTimeInMillis(tsEnd * 1000L); day.set(Calendar.HOUR_OF_DAY, 0); day.set(Calendar.MINUTE, 0); day.set(Calendar.SECOND, 0); final int tsStart = (int) (day.getTimeInMillis() / 1000); tsEnd = tsStart + 24 * 60 * 60 - 1; final List samples = getSamples(db, device, tsStart, tsEnd); LOG.info("Got {} stress samples", samples.size()); ensureStartAndEndSamples((List) samples, tsStart, tsEnd); return new StressChartsDataBuilder(samples, device.getDeviceCoordinator().getStressRanges()).build(); } protected LineDataSet createDataSet(final StressType stressType, final List values) { final LineDataSet lineDataSet = new LineDataSet(values, stressType.getLabel(requireContext())); lineDataSet.setColor(stressType.getColor(requireContext())); lineDataSet.setDrawFilled(true); lineDataSet.setDrawCircles(false); lineDataSet.setFillColor(stressType.getColor(requireContext())); lineDataSet.setFillAlpha(255); lineDataSet.setDrawValues(false); lineDataSet.setValueTextColor(CHART_TEXT_COLOR); lineDataSet.setAxisDependency(YAxis.AxisDependency.LEFT); return lineDataSet; } @Override protected void updateChartsnUIThread(final StressChartsData stressData) { final PieData pieData = stressData.getPieData(); Date date = new Date((long) this.getTSEnd() * 1000); String formattedDate = new SimpleDateFormat("E, MMM dd").format(date); stressDate.setText(formattedDate); Map stressZoneTimes = stressData.getStressZoneTimes(); Integer relaxedTime = stressZoneTimes.get(StressType.RELAXED); if (0 < relaxedTime) { stressChartRelaxedTime.setText(DateTimeUtils.formatDurationHoursMinutes(relaxedTime, TimeUnit.SECONDS)); } else { stressChartRelaxedTime.setText(R.string.stats_empty_value); } Integer mildTime = stressZoneTimes.get(StressType.MILD); if (mildTime > 0) { stressChartMildTime.setText(DateTimeUtils.formatDurationHoursMinutes(mildTime, TimeUnit.SECONDS)); } else { stressChartMildTime.setText(R.string.stats_empty_value); } Integer moderateTime = stressZoneTimes.get(StressType.MODERATE); if (moderateTime > 0) { stressChartModerateTime.setText(DateTimeUtils.formatDurationHoursMinutes(moderateTime, TimeUnit.SECONDS)); } else { stressChartModerateTime.setText(R.string.stats_empty_value); } Integer highTime = stressZoneTimes.get(StressType.HIGH); if (highTime > 0) { stressChartHighTime.setText(DateTimeUtils.formatDurationHoursMinutes(highTime, TimeUnit.SECONDS)); } else { stressChartHighTime.setText(R.string.stats_empty_value); } if (stressData.getAverage() > 0) { int noc = String.valueOf(stressData.getAverage()).length(); SpannableString pieChartCenterText = new SpannableString(stressData.getAverage() + "\n" + requireContext().getString(R.string.stress_average)); pieChartCenterText.setSpan(new RelativeSizeSpan(1.75f), 0, noc, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); pieChartCenterText.setSpan(new RelativeSizeSpan(0.72f), noc, pieChartCenterText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); mStressLevelsPieChart.setCenterText(pieChartCenterText); } else { SpannableString pieChartCenterText = new SpannableString("-\n" + requireContext().getString(R.string.stress_average)); pieChartCenterText.setSpan(new RelativeSizeSpan(1.25f), 0, 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); pieChartCenterText.setSpan(new RelativeSizeSpan(0.72f), 2, pieChartCenterText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); mStressLevelsPieChart.setCenterText(pieChartCenterText); } mStressLevelsPieChart.setData(pieData); final DefaultChartsData chartsData = stressData.getChartsData(); mStressChart.setData(null); // workaround for https://github.com/PhilJay/MPAndroidChart/issues/2317 mStressChart.getXAxis().setValueFormatter(chartsData.getXValueFormatter()); mStressChart.setData(chartsData.getData()); mStressChart.getAxisRight().removeAllLimitLines(); if (stressData.getAverage() > 0) { final LimitLine averageLine = new LimitLine(stressData.getAverage()); averageLine.setLineColor(Color.GRAY); averageLine.setLineWidth(1.5f); averageLine.enableDashedLine(15f, 10f, 0f); mStressChart.getAxisRight().addLimitLine(averageLine); } } @Override public String getTitle() { return getString(R.string.menuitem_stress); } @Override public View onCreateView(final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { final View rootView = inflater.inflate(R.layout.fragment_stresschart, container, false); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { rootView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> { getChartsHost().enableSwipeRefresh(scrollY == 0); }); } mStressChart = rootView.findViewById(R.id.stress_line_chart); mStressLevelsPieChart = rootView.findViewById(R.id.stress_pie_chart); stressChartRelaxedTime = rootView.findViewById(R.id.stress_chart_relaxed_time); stressChartMildTime = rootView.findViewById(R.id.stress_chart_mild_time); stressChartModerateTime = rootView.findViewById(R.id.stress_chart_moderate_time); stressChartHighTime = rootView.findViewById(R.id.stress_chart_high_time); stressDate = rootView.findViewById(R.id.stress_date); setupLineChart(); setupPieChart(); // refresh immediately instead of use refreshIfVisible(), for perceived performance refresh(); return rootView; } private void setupPieChart() { mStressLevelsPieChart.setBackgroundColor(BACKGROUND_COLOR); mStressLevelsPieChart.getDescription().setTextColor(DESCRIPTION_COLOR); mStressLevelsPieChart.setEntryLabelColor(DESCRIPTION_COLOR); mStressLevelsPieChart.getDescription().setText(""); mStressLevelsPieChart.setNoDataText(""); mStressLevelsPieChart.setTouchEnabled(false); mStressLevelsPieChart.setCenterTextColor(GBApplication.getTextColor(getContext())); mStressLevelsPieChart.setCenterTextSize(18f); mStressLevelsPieChart.setHoleColor(getContext().getResources().getColor(R.color.transparent)); mStressLevelsPieChart.setHoleRadius(85); mStressLevelsPieChart.setDrawEntryLabels(false); mStressLevelsPieChart.getLegend().setEnabled(false); } private void setupLineChart() { mStressChart.setBackgroundColor(BACKGROUND_COLOR); mStressChart.getDescription().setTextColor(DESCRIPTION_COLOR); configureBarLineChartDefaults(mStressChart); final XAxis x = mStressChart.getXAxis(); x.setDrawLabels(true); x.setDrawGridLines(false); x.setEnabled(true); x.setTextColor(CHART_TEXT_COLOR); x.setDrawLimitLinesBehindData(true); x.setAxisMinimum(0f); x.setAxisMaximum(86400f); final YAxis yAxisLeft = mStressChart.getAxisLeft(); yAxisLeft.setDrawGridLines(true); yAxisLeft.setAxisMaximum(100f); yAxisLeft.setAxisMinimum(0); yAxisLeft.setDrawTopYLabelEntry(false); yAxisLeft.setTextColor(CHART_TEXT_COLOR); yAxisLeft.setEnabled(true); final YAxis yAxisRight = mStressChart.getAxisRight(); yAxisRight.setDrawGridLines(false); yAxisRight.setEnabled(true); yAxisRight.setDrawLabels(false); yAxisRight.setDrawTopYLabelEntry(true); yAxisRight.setTextColor(CHART_TEXT_COLOR); yAxisRight.setAxisMaximum(100f); yAxisRight.setAxisMinimum(0); } @Override protected void setupLegend(final Chart chart) { final List legendEntries = new ArrayList<>(StressType.values().length + 1); for (final StressType stressType : StressType.values()) { final LegendEntry entry = new LegendEntry(); entry.label = stressType.getLabel(requireContext()); entry.formColor = stressType.getColor(requireContext()); legendEntries.add(entry); } if (!CHARTS_SLEEP_RANGE_24H && SHOW_CHARTS_AVERAGE) { final LegendEntry averageEntry = new LegendEntry(); averageEntry.label = STRESS_AVERAGE_LABEL; averageEntry.formColor = Color.GRAY; legendEntries.add(averageEntry); } chart.getLegend().setCustom(legendEntries); chart.getLegend().setTextColor(LEGEND_TEXT_COLOR); } @Override protected void renderCharts() { mStressChart.animateX(ANIM_TIME, Easing.EaseInOutQuart); mStressLevelsPieChart.invalidate(); } private List getSamples(final DBHandler db, final GBDevice device, int tsStart, int tsEnd) { final DeviceCoordinator coordinator = device.getDeviceCoordinator(); final TimeSampleProvider sampleProvider = coordinator.getStressSampleProvider(device, db.getDaoSession()); return sampleProvider.getAllSamples(tsStart * 1000L, tsEnd * 1000L); } protected void ensureStartAndEndSamples(final List samples, int tsStart, int tsEnd) { if (samples == null || samples.isEmpty()) { return; } final long tsEndMillis = tsEnd * 1000L; final long tsStartMillis = tsStart * 1000L; final StressSample lastSample = samples.get(samples.size() - 1); if (lastSample.getTimestamp() < tsEndMillis) { samples.add(new EmptyStressSample(tsEndMillis)); } final StressSample firstSample = samples.get(0); if (firstSample.getTimestamp() > tsStartMillis) { samples.add(0, new EmptyStressSample(tsStartMillis)); } } protected static final class EmptyStressSample implements StressSample { private final long ts; public EmptyStressSample(final long ts) { this.ts = ts; } @Override public Type getType() { return Type.AUTOMATIC; } @Override public int getStress() { return -1; } @Override public long getTimestamp() { return ts; } } protected class StressChartsDataBuilder { private static final int UNKNOWN_VAL = 2; private final List samples; private final int[] stressRanges; private final TimestampTranslation tsTranslation = new TimestampTranslation(); private final Map> lineEntriesPerLevel = new HashMap<>(); private final Map accumulator = new HashMap<>(); int previousTs; int currentTypeStartTs; StressType previousStressType; long averageSum; long averageNumSamples; public StressChartsDataBuilder(final List samples, final int[] stressRanges) { this.samples = samples; this.stressRanges = stressRanges; } private void reset() { tsTranslation.reset(); lineEntriesPerLevel.clear(); accumulator.clear(); for (final StressType stressType : StressType.values()) { lineEntriesPerLevel.put(stressType, new ArrayList<>()); accumulator.put(stressType, 0); } previousTs = 0; currentTypeStartTs = 0; previousStressType = StressType.UNKNOWN; } private void processSamples() { reset(); for (final StressSample sample : samples) { processSample(sample); } // Add the last block, if any if (currentTypeStartTs != previousTs) { set(previousTs, previousStressType, samples.get(samples.size() - 1).getStress()); } } private void processSample(final StressSample sample) { //LOG.debug("Processing sample {} {}", sdf.format(new Date(sample.getTimestamp())), sample.getStress()); final StressType stressType = StressType.fromStress(sample.getStress(), stressRanges); final int ts = tsTranslation.shorten((int) (sample.getTimestamp() / 1000L)); if (ts == 0) { // First sample previousTs = ts; currentTypeStartTs = ts; previousStressType = stressType; set(ts, stressType, sample.getStress()); return; } if (ts - previousTs > 60 * 10) { // More than 15 minutes since last sample // Set to unknown right after the last sample we got until the current time int lastEndTs = Math.min(previousTs + 60 * 5, ts - 1); set(lastEndTs, StressType.UNKNOWN, UNKNOWN_VAL); set(ts - 1, StressType.UNKNOWN, UNKNOWN_VAL); } if (!stressType.equals(previousStressType)) { currentTypeStartTs = ts; } set(ts, stressType, sample.getStress()); accumulator.put(stressType, accumulator.get(stressType) + 60); if (stressType != StressType.UNKNOWN) { averageSum += sample.getStress(); averageNumSamples++; } previousStressType = stressType; previousTs = ts; } private void set(final int ts, final StressType stressType, final int stress) { for (final Map.Entry> stressTypeListEntry : lineEntriesPerLevel.entrySet()) { if (stressTypeListEntry.getKey() == stressType) { stressTypeListEntry.getValue().add(new Entry(ts, stress)); } else { stressTypeListEntry.getValue().add(new Entry(ts, 0)); } } } public StressChartsData build() { processSamples(); final List lineDataSets = new ArrayList<>(); final List pieEntries = new ArrayList<>(); final List pieColors = new ArrayList<>(); final Map stressZoneTimes = new HashMap<>(); for (final StressType stressType : StressType.values()) { final List stressEntries = lineEntriesPerLevel.get(stressType); lineDataSets.add(createDataSet(stressType, stressEntries)); final Integer stressTime = accumulator.get(stressType); stressZoneTimes.put(stressType, stressTime); if (stressType != StressType.UNKNOWN && stressTime != null && stressTime != 0) { pieEntries.add(new PieEntry(stressTime, stressType.getLabel(requireContext()))); pieColors.add(stressType.getColor(requireContext())); } } if (pieEntries.isEmpty()) { pieEntries.add(new PieEntry(1)); pieColors.add(getResources().getColor(R.color.gauge_line_color)); } final PieDataSet pieDataSet = new PieDataSet(pieEntries, ""); pieDataSet.setValueFormatter(new ValueFormatter() { @Override public String getFormattedValue(float value) { return DateTimeUtils.formatDurationHoursMinutes((long) value, TimeUnit.SECONDS); } }); pieDataSet.setColors(pieColors); pieDataSet.setValueTextColor(DESCRIPTION_COLOR); pieDataSet.setValueTextSize(13f); pieDataSet.setXValuePosition(PieDataSet.ValuePosition.OUTSIDE_SLICE); pieDataSet.setYValuePosition(PieDataSet.ValuePosition.OUTSIDE_SLICE); pieDataSet.setDrawValues(false); pieDataSet.setSliceSpace(2f); final PieData pieData = new PieData(pieDataSet); final LineData lineData = new LineData(lineDataSets); final ValueFormatter xValueFormatter = new SampleXLabelFormatter(tsTranslation, "HH:mm"); final DefaultChartsData chartsData = new DefaultChartsData<>(lineData, xValueFormatter); return new StressChartsData(pieData, chartsData, Math.round((float) averageSum / averageNumSamples), stressZoneTimes); } } protected static class StressChartsData extends ChartsData { private final PieData pieData; private final DefaultChartsData chartsData; private final int average; private Map stressZoneTimes; public StressChartsData(final PieData pieData, final DefaultChartsData chartsData, final int average, Map stressZoneTimes) { this.pieData = pieData; this.chartsData = chartsData; this.average = average; this.stressZoneTimes = stressZoneTimes; } public Map getStressZoneTimes() { return stressZoneTimes; } public PieData getPieData() { return pieData; } public DefaultChartsData getChartsData() { return chartsData; } public int getAverage() { return average; } } public enum StressType { UNKNOWN(R.string.unknown, R.color.chart_stress_unknown), RELAXED(R.string.stress_relaxed, R.color.chart_stress_relaxed), MILD(R.string.stress_mild, R.color.chart_stress_mild), MODERATE(R.string.stress_moderate, R.color.chart_stress_moderate), HIGH(R.string.stress_high, R.color.chart_stress_high), ; private final int labelId; private final int colorId; StressType(final int labelId, final int colorId) { this.labelId = labelId; this.colorId = colorId; } public String getLabel(final Context context) { return context.getString(labelId); } public int getColor(final Context context) { return ContextCompat.getColor(context, colorId); } public static StressType fromStress(final int stress, final int[] stressRanges) { if (stress < stressRanges[0]) { return StressType.UNKNOWN; } else if (stress < stressRanges[1]) { return StressType.RELAXED; } else if (stress < stressRanges[2]) { return StressType.MILD; } else if (stress < stressRanges[3]) { return StressType.MODERATE; } else { return StressType.HIGH; } } } }