/* Copyright (C) 2023-2024 Daniel Dakhno, José Rebelo, a0z 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.util.TypedValue; import androidx.core.content.ContextCompat; 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.apache.commons.lang3.NotImplementedException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.Calendar; import java.util.GregorianCalendar; import java.util.List; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.HeartRateUtils; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider; import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; public abstract class AbstractActivityChartFragment extends AbstractChartFragment { private static final Logger LOG = LoggerFactory.getLogger(AbstractActivityChartFragment.class); public static final float Y_VALUE_DEEP_SLEEP = 0.01f; public boolean supportsHeartrate(GBDevice device) { DeviceCoordinator coordinator = device.getDeviceCoordinator(); return coordinator.supportsHeartRateMeasurement(device); } public boolean supportsRemSleep(GBDevice device) { DeviceCoordinator coordinator = device.getDeviceCoordinator(); return coordinator.supportsRemSleep(); } public boolean supportsAwakeSleep(GBDevice device) { DeviceCoordinator coordinator = device.getDeviceCoordinator(); return coordinator.supportsAwakeSleep(); } protected static final class ActivityConfig { public final ActivityKind type; public final String label; public final Integer color; public ActivityConfig(ActivityKind kind, String label, Integer color) { this.type = kind; this.label = label; this.color = color; } } protected ActivityConfig akActivity; protected ActivityConfig akLightSleep; protected ActivityConfig akDeepSleep; protected ActivityConfig akRemSleep; protected ActivityConfig akAwakeSleep; protected ActivityConfig akNotWorn; protected int BACKGROUND_COLOR; protected int DESCRIPTION_COLOR; protected int CHART_TEXT_COLOR; protected int LEGEND_TEXT_COLOR; protected int HEARTRATE_COLOR; protected int HEARTRATE_FILL_COLOR; protected int AK_ACTIVITY_COLOR; protected int AK_DEEP_SLEEP_COLOR; protected int AK_REM_SLEEP_COLOR; protected int AK_AWAKE_SLEEP_COLOR; protected int AK_LIGHT_SLEEP_COLOR; protected int AK_NOT_WORN_COLOR; protected String HEARTRATE_LABEL; protected String HEARTRATE_AVERAGE_LABEL; @Override protected void init() { Prefs prefs = GBApplication.getPrefs(); TypedValue runningColor = new TypedValue(); BACKGROUND_COLOR = GBApplication.getBackgroundColor(getContext()); LEGEND_TEXT_COLOR = DESCRIPTION_COLOR = GBApplication.getTextColor(getContext()); CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(getContext()); if (prefs.getBoolean("chart_heartrate_color", false)) { HEARTRATE_COLOR = ContextCompat.getColor(getContext(), R.color.chart_heartrate_alternative); }else{ HEARTRATE_COLOR = ContextCompat.getColor(getContext(), R.color.chart_heartrate); } HEARTRATE_FILL_COLOR = ContextCompat.getColor(getContext(), R.color.chart_heartrate_fill); getContext().getTheme().resolveAttribute(R.attr.chart_activity, runningColor, true); AK_ACTIVITY_COLOR = runningColor.data; getContext().getTheme().resolveAttribute(R.attr.chart_deep_sleep, runningColor, true); AK_DEEP_SLEEP_COLOR = runningColor.data; getContext().getTheme().resolveAttribute(R.attr.chart_light_sleep, runningColor, true); AK_LIGHT_SLEEP_COLOR = runningColor.data; getContext().getTheme().resolveAttribute(R.attr.chart_rem_sleep, runningColor, true); AK_REM_SLEEP_COLOR = runningColor.data; getContext().getTheme().resolveAttribute(R.attr.chart_awake_sleep, runningColor, true); AK_AWAKE_SLEEP_COLOR = runningColor.data; getContext().getTheme().resolveAttribute(R.attr.chart_not_worn, runningColor, true); AK_NOT_WORN_COLOR = runningColor.data; HEARTRATE_LABEL = getContext().getString(R.string.charts_legend_heartrate); HEARTRATE_AVERAGE_LABEL = getContext().getString(R.string.charts_legend_heartrate_average); akActivity = new ActivityConfig(ActivityKind.ACTIVITY, getString(R.string.abstract_chart_fragment_kind_activity), AK_ACTIVITY_COLOR); akLightSleep = new ActivityConfig(ActivityKind.LIGHT_SLEEP, getString(R.string.abstract_chart_fragment_kind_light_sleep), AK_LIGHT_SLEEP_COLOR); akDeepSleep = new ActivityConfig(ActivityKind.DEEP_SLEEP, getString(R.string.abstract_chart_fragment_kind_deep_sleep), AK_DEEP_SLEEP_COLOR); akRemSleep = new ActivityConfig(ActivityKind.REM_SLEEP, getString(R.string.abstract_chart_fragment_kind_rem_sleep), AK_REM_SLEEP_COLOR); akAwakeSleep = new ActivityConfig(ActivityKind.REM_SLEEP, getString(R.string.abstract_chart_fragment_kind_awake_sleep), AK_AWAKE_SLEEP_COLOR); akNotWorn = new ActivityConfig(ActivityKind.NOT_WORN, getString(R.string.abstract_chart_fragment_kind_not_worn), AK_NOT_WORN_COLOR); } @Override protected boolean isSingleDay() { return false; } protected Integer getColorFor(ActivityKind activityKind) { switch (activityKind) { case DEEP_SLEEP: return akDeepSleep.color; case LIGHT_SLEEP: return akLightSleep.color; case REM_SLEEP: return akRemSleep.color; case AWAKE_SLEEP: return akAwakeSleep.color; case ACTIVITY: return akActivity.color; } return akActivity.color; } protected SampleProvider getProvider(DBHandler db, GBDevice device) { DeviceCoordinator coordinator = device.getDeviceCoordinator(); return coordinator.getSampleProvider(device, db.getDaoSession()); } /** * Returns all kinds of samples for the given device. * To be called from a background thread. * * @param device * @param tsFrom * @param tsTo */ protected List getAllSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) { SampleProvider provider = getProvider(db, device); return provider.getAllActivitySamples(tsFrom, tsTo); } protected List getAllSamplesHighRes(DBHandler db, GBDevice device, int tsFrom, int tsTo) { SampleProvider provider = getProvider(db, device); // Only retrieve if the provider signals it has high res data, otherwise it is useless if (provider.hasHighResData()) return provider.getAllActivitySamplesHighRes(tsFrom, tsTo); return null; } protected List getActivitySamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) { SampleProvider provider = getProvider(db, device); return provider.getActivitySamples(tsFrom, tsTo); } public DefaultChartsData refresh(GBDevice gbDevice, List samples) { // If there is no high res samples, all the samples are high res samples return refresh(gbDevice, samples, samples); } public DefaultChartsData refresh(GBDevice gbDevice, List samples, List highResSamples) { TimestampTranslation tsTranslation = new TimestampTranslation(); LOG.info("{}: number of samples: {}", getTitle(), samples.size()); LOG.info("{}: number of high res samples: {}", getTitle(), highResSamples.size()); LineData lineData; if (samples.isEmpty()) { lineData = new LineData(); ValueFormatter xValueFormatter = new SampleXLabelFormatter(tsTranslation, "HH:mm"); return new DefaultChartsData<>(lineData, xValueFormatter); } ActivityKind last_type = ActivityKind.UNKNOWN; float last_value = 0; int numEntries = samples.size(); List> entries = new ArrayList<>(); for (int i = 0; i < 6; i++) { entries.add(new ArrayList<>()); } for (int i = 0; i < numEntries; i++) { ActivitySample sample = samples.get(i); ActivityKind type = sample.getKind(); int ts = tsTranslation.shorten(sample.getTimestamp()); final float value; if (type != ActivityKind.NOT_WORN) { if (ActivityKind.isSleep(type) && sample.getIntensity() < 0) { switch (type) { case SLEEP_ANY: case AWAKE_SLEEP: value = 0.25f; break; case DEEP_SLEEP: value = 0.10f; break; case LIGHT_SLEEP: value = 0.15f; break; case REM_SLEEP: value = 0.20f; break; default: value = Y_VALUE_DEEP_SLEEP; break; } } else { value = sample.getIntensity(); } } else { value = Y_VALUE_DEEP_SLEEP; } // do not interpolate NOT_WORN on any side boolean interpolate = !(last_type == ActivityKind.NOT_WORN || type == ActivityKind.NOT_WORN); float interpolation_value = interpolate ? value : last_value; // filled charts int index = getIndexOfActivity(type); int last_index = getIndexOfActivity(last_type); if (last_type != type) { entries.get(index).add(createLineEntry(0, ts)); entries.get(last_index).add(createLineEntry(interpolation_value, ts)); entries.get(last_index).add(createLineEntry(0, ts)); } entries.get(index).add(createLineEntry(value, ts)); last_type = type; last_value = value; } boolean hr = supportsHeartrate(gbDevice); final List heartRateLineEntries = new ArrayList<>(); final List heartRateDataSets = new ArrayList<>(); int lastTsShorten = 0; HeartRateUtils heartRateUtilsInstance = HeartRateUtils.getInstance(); // Currently only for HR if (hr) { for (ActivitySample sample : highResSamples) { if (sample.getKind() != ActivityKind.NOT_WORN && heartRateUtilsInstance.isValidHeartRateValue(sample.getHeartRate())) { int tsShorten = tsTranslation.shorten(sample.getTimestamp()); if (lastTsShorten == 0 || (tsShorten - lastTsShorten) <= 60 * HeartRateUtils.MAX_HR_MEASUREMENTS_GAP_MINUTES) { heartRateLineEntries.add(new Entry(tsShorten, sample.getHeartRate())); } else { if (!heartRateLineEntries.isEmpty()) { List clone = new ArrayList<>(heartRateLineEntries.size()); clone.addAll(heartRateLineEntries); heartRateDataSets.add(createHeartrateSet(clone, "Heart Rate")); heartRateLineEntries.clear(); } } lastTsShorten = tsShorten; heartRateLineEntries.add(new Entry(tsShorten, sample.getHeartRate())); } } } if (!heartRateLineEntries.isEmpty()) { heartRateDataSets.add(createHeartrateSet(heartRateLineEntries, "Heart Rate")); } // convert Entry Lists to Datasets List lineDataSets = new ArrayList<>(); lineDataSets.add(createDataSet( entries.get(getIndexOfActivity(ActivityKind.ACTIVITY)), akActivity.color, "Activity" )); lineDataSets.add(createDataSet( entries.get(getIndexOfActivity(ActivityKind.DEEP_SLEEP)), akDeepSleep.color, "Deep Sleep" )); lineDataSets.add(createDataSet( entries.get(getIndexOfActivity(ActivityKind.LIGHT_SLEEP)), akLightSleep.color, "Light Sleep" )); lineDataSets.add(createDataSet( entries.get(getIndexOfActivity(ActivityKind.NOT_WORN)), akNotWorn.color, "Not worn" )); if (supportsRemSleep(gbDevice)) { lineDataSets.add(createDataSet( entries.get(getIndexOfActivity(ActivityKind.REM_SLEEP)), akRemSleep.color, "REM Sleep" )); } if (supportsAwakeSleep(gbDevice)) { lineDataSets.add(createDataSet( entries.get(getIndexOfActivity(ActivityKind.AWAKE_SLEEP)), akAwakeSleep.color, "Awake Sleep" )); } if (hr && !heartRateDataSets.isEmpty()) { lineDataSets.addAll(heartRateDataSets); } lineData = new LineData(lineDataSets); ValueFormatter xValueFormatter = new SampleXLabelFormatter(tsTranslation, "HH:mm"); return new DefaultChartsData<>(lineData, xValueFormatter); } protected int getIndexOfActivity(ActivityKind kind) { switch (kind) { case DEEP_SLEEP: return 0; case LIGHT_SLEEP: return 1; case REM_SLEEP: return 2; case AWAKE_SLEEP: return 3; case NOT_WORN: return 4; default: return 5; // treated as ActivityKind.ACTIVITY } } protected Entry createLineEntry(float value, int xValue) { return new Entry(xValue, value); } protected LineDataSet createDataSet(List values, Integer color, String label) { LineDataSet set1 = new LineDataSet(values, label); set1.setColor(color); set1.setDrawFilled(true); set1.setDrawCircles(false); set1.setFillColor(color); set1.setFillAlpha(255); set1.setDrawValues(false); set1.setValueTextColor(CHART_TEXT_COLOR); set1.setAxisDependency(YAxis.AxisDependency.LEFT); return set1; } protected LineDataSet createHeartrateSet(List values, String label) { LineDataSet set1 = new LineDataSet(values, label); set1.setLineWidth(2.2f); set1.setColor(HEARTRATE_COLOR); set1.setMode(LineDataSet.Mode.HORIZONTAL_BEZIER); set1.setCubicIntensity(0.1f); set1.setDrawCircles(false); set1.setDrawValues(true); set1.setValueTextColor(CHART_TEXT_COLOR); set1.setAxisDependency(YAxis.AxisDependency.RIGHT); return set1; } /** * Implement this to supply the samples to be displayed. */ protected abstract List getSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo); /** * Implement this to supply high resolution data */ protected List getSamplesHighRes(DBHandler db, GBDevice device, int tsFrom, int tsTo) { throw new NotImplementedException("High resolution samples have not been implemented for this chart."); } protected List getSamples(DBHandler db, GBDevice device) { int tsStart = getTSStart(); int tsEnd = getTSEnd(); List samples = (List) getSamples(db, device, tsStart, tsEnd); ensureStartAndEndSamples(samples, tsStart, tsEnd); // List samples2 = new ArrayList<>(); // int min = Math.min(samples.size(), 10); // int min = Math.min(samples.size(), 10); // for (int i = 0; i < min; i++) { // samples2.add(samples.get(i)); // } // return samples2; return samples; } protected List getSamplesHighRes(DBHandler db, GBDevice device) { int tsStart = getTSStart(); int tsEnd = getTSEnd(); return getSamplesHighRes(db, device, tsStart, tsEnd); } protected List getSamplesofSleep(DBHandler db, GBDevice device) { int SLEEP_HOUR_LIMIT = 12; int tsStart = getTSStart(); Calendar day = GregorianCalendar.getInstance(); day.setTimeInMillis(tsStart * 1000L); day.set(Calendar.HOUR_OF_DAY, SLEEP_HOUR_LIMIT); day.set(Calendar.MINUTE, 0); day.set(Calendar.SECOND, 0); tsStart = toTimestamp(day.getTime()); int tsEnd = getTSEnd(); day.setTimeInMillis(tsEnd* 1000L); day.set(Calendar.HOUR_OF_DAY, SLEEP_HOUR_LIMIT); day.set(Calendar.MINUTE, 0); day.set(Calendar.SECOND, 0); tsEnd = toTimestamp(day.getTime()); List samples = (List) getSamples(db, device, tsStart, tsEnd); ensureStartAndEndSamples(samples, tsStart, tsEnd); return samples; } protected void ensureStartAndEndSamples(List samples, int tsStart, int tsEnd) { if (samples == null || samples.isEmpty()) { return; } ActivitySample lastSample = samples.get(samples.size() - 1); if (lastSample.getTimestamp() < tsEnd) { samples.add(createTrailingActivitySample(lastSample, tsEnd)); } ActivitySample firstSample = samples.get(0); if (firstSample.getTimestamp() > tsStart) { samples.add(createTrailingActivitySample(firstSample, tsStart)); } } private ActivitySample createTrailingActivitySample(ActivitySample referenceSample, int timestamp) { TrailingActivitySample sample = new TrailingActivitySample(); if (referenceSample instanceof AbstractActivitySample) { AbstractActivitySample reference = (AbstractActivitySample) referenceSample; sample.setUserId(reference.getUserId()); sample.setDeviceId(reference.getDeviceId()); sample.setProvider(reference.getProvider()); } sample.setTimestamp(timestamp); return sample; } }