/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele Gobbetti, Dikay900, José Rebelo, ozkanpakdil, Pavel Elagin, Petr Vaněk, Q-er, 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.content.Context; import android.content.Intent; import android.graphics.Color; import android.os.Build; import android.os.Bundle; import android.text.method.ScrollingMovementMethod; import android.util.TypedValue; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.LinearLayout; 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.components.*; import com.github.mikephil.charting.data.LineData; import org.apache.commons.lang3.tuple.Triple; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.text.DecimalFormat; import java.text.SimpleDateFormat; import java.util.*; import java.util.concurrent.TimeUnit; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.HeartRateUtils; import nodomain.freeyourgadget.gadgetbridge.activities.charts.SleepAnalysis.SleepSession; import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.GaugeDrawer; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample; import nodomain.freeyourgadget.gadgetbridge.model.SleepScoreSample; import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; public class DaySleepChartFragment extends AbstractActivityChartFragment { protected static final Logger LOG = LoggerFactory.getLogger(ActivitySleepChartFragment.class); private LineChart mActivityChart; private ImageView sleepStagesGauge; private TextView mSleepchartInfo; private TextView remSleepTimeText; private LinearLayout remSleepTimeTextWrapper; private TextView awakeSleepTimeText; private LinearLayout awakeSleepTimeTextWrapper; private TextView deepSleepTimeText; private TextView lightSleepTimeText; private TextView lowestHrText; private TextView highestHrText; private TextView movementIntensityText; private LinearLayout movementIntensityTextWrapper; private LinearLayout dummyTile; private TextView sleepDateText; private int heartRateMin = 0; private int heartRateMax = 0; private float intensityTotal = 0; private int mSmartAlarmFrom = -1; private int mSmartAlarmTo = -1; private int mTimestampFrom = -1; private int mSmartAlarmGoneOff = -1; Prefs prefs = GBApplication.getPrefs(); private boolean CHARTS_SLEEP_RANGE_24H = prefs.getBoolean("chart_sleep_range_24h", false); private boolean SHOW_CHARTS_AVERAGE = prefs.getBoolean("charts_show_average", true); private int sleepLinesLimit = prefs.getInt("chart_sleep_lines_limit", 6); @Override protected boolean isSingleDay() { return true; } @Override protected MyChartsData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) { List samples; if (CHARTS_SLEEP_RANGE_24H) { samples = getSamples(db, device); } else { samples = getSamplesofSleep(db, device); } List sleepScoreSamples = new ArrayList<>(); if (supportsSleepScore()) { sleepScoreSamples = getSleepScoreSamples(db, device, getTSStart(), getTSEnd()); } MySleepChartsData mySleepChartsData = refreshSleepAmounts(device, samples, sleepScoreSamples); if (!CHARTS_SLEEP_RANGE_24H) { if (mySleepChartsData.sleepSessions.size() > 0) { long tstart = mySleepChartsData.sleepSessions.get(0).getSleepStart().getTime() / 1000; long tend = mySleepChartsData.sleepSessions.get(mySleepChartsData.sleepSessions.size() - 1).getSleepEnd().getTime() / 1000; for (Iterator iterator = (Iterator) samples.iterator(); iterator.hasNext(); ) { ActivitySample sample = iterator.next(); if (sample.getTimestamp() < tstart || sample.getTimestamp() > tend) { iterator.remove(); } } } } DefaultChartsData chartsData = refresh(device, samples); Triple hrData = calculateHrData(samples); Triple intensityData = calculateIntensityData(samples); return new MyChartsData(mySleepChartsData, chartsData, hrData.getLeft(), hrData.getMiddle(), hrData.getRight(), intensityData.getLeft(), intensityData.getMiddle(), intensityData.getRight()); } private MySleepChartsData refreshSleepAmounts(GBDevice mGBDevice, List samples, List sleepScoreSamples) { SleepAnalysis sleepAnalysis = new SleepAnalysis(); List sleepSessions = sleepAnalysis.calculateSleepSessions(samples); final long lightSleepDuration = calculateLightSleepDuration(sleepSessions); final long deepSleepDuration = calculateDeepSleepDuration(sleepSessions); final long remSleepDuration = calculateRemSleepDuration(sleepSessions); final long awakeSleepDuration = calculateAwakeSleepDuration(sleepSessions); final long totalSeconds = lightSleepDuration + deepSleepDuration + remSleepDuration; int sleepScore = 0; if (!sleepScoreSamples.isEmpty()) { sleepScore = sleepScoreSamples.get(sleepScoreSamples.size() - 1).getSleepScore(); } return new MySleepChartsData(sleepSessions, totalSeconds, awakeSleepDuration, remSleepDuration, deepSleepDuration, lightSleepDuration, sleepScore); } private long calculateLightSleepDuration(List sleepSessions) { long result = 0; for (SleepSession sleepSession : sleepSessions) { result += sleepSession.getLightSleepDuration(); } return result; } private long calculateDeepSleepDuration(List sleepSessions) { long result = 0; for (SleepSession sleepSession : sleepSessions) { result += sleepSession.getDeepSleepDuration(); } return result; } private long calculateRemSleepDuration(List sleepSessions) { long result = 0; for (SleepSession sleepSession : sleepSessions) { result += sleepSession.getRemSleepDuration(); } return result; } private long calculateAwakeSleepDuration(List sleepSessions) { long result = 0; for (SleepSession sleepSession : sleepSessions) { result += sleepSession.getAwakeSleepDuration(); } return result; } protected void sleepStagesGaugeUpdate(MySleepChartsData pieData) { int[] colors = new int[] { ContextCompat.getColor(GBApplication.getContext(), R.color.chart_light_sleep_light), ContextCompat.getColor(GBApplication.getContext(), R.color.chart_deep_sleep_light), ContextCompat.getColor(GBApplication.getContext(), R.color.chart_rem_sleep_light), ContextCompat.getColor(GBApplication.getContext(), R.color.chart_awake_sleep_light), }; long total = pieData.getTotalSleep() + pieData.getTotalAwake(); float[] segments = new float[] { pieData.getTotalLight() > 0 ? (float) pieData.getTotalLight() / total : 0, pieData.getTotalDeep() > 0 ? (float) pieData.getTotalDeep() / total : 0, pieData.getTotalRem() > 0 ? (float) pieData.getTotalRem() / total : 0, pieData.getTotalAwake() > 0 ? (float) pieData.getTotalAwake() / total : 0, }; final int width = (int) TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, 300, GBApplication.getContext().getResources().getDisplayMetrics() ); String lowerText = ""; if (supportsSleepScore()) { lowerText = GBApplication.getContext().getString(R.string.sleep_score_value, pieData.getSleepScore()); } sleepStagesGauge.setImageBitmap(GaugeDrawer.drawCircleGaugeSegmented( width, width / 15, colors, segments, true, String.valueOf(timeStringFormat(pieData.getTotalSleep())), lowerText, getContext() )); } private String timeStringFormat(long seconds) { return DateTimeUtils.formatDurationHoursMinutes(seconds, TimeUnit.SECONDS); } @Override protected void updateChartsnUIThread(MyChartsData mcd) { MySleepChartsData pieData = mcd.getPieData(); Date date = new Date((long) this.getTSEnd() * 1000); String formattedDate = new SimpleDateFormat("E, MMM dd").format(date); sleepDateText.setText(formattedDate); sleepStagesGaugeUpdate(pieData); if (!pieData.sleepSessions.isEmpty()) { awakeSleepTimeText.setText(timeStringFormat(pieData.getTotalAwake())); remSleepTimeText.setText(timeStringFormat(pieData.getTotalRem())); deepSleepTimeText.setText(timeStringFormat(pieData.getTotalDeep())); lightSleepTimeText.setText(timeStringFormat(pieData.getTotalLight())); } else { awakeSleepTimeText.setText("-"); remSleepTimeText.setText("-"); deepSleepTimeText.setText("-"); lightSleepTimeText.setText("-"); } if (!supportsRemSleep(getChartsHost().getDevice())) { remSleepTimeTextWrapper.setVisibility(View.GONE); } if (!supportsAwakeSleep(getChartsHost().getDevice())) { awakeSleepTimeTextWrapper.setVisibility(View.GONE); } mSleepchartInfo.setText(buildYouSleptText(pieData)); mSleepchartInfo.setMovementMethod(new ScrollingMovementMethod()); mActivityChart.setData(null); // workaround for https://github.com/PhilJay/MPAndroidChart/issues/2317 mActivityChart.getXAxis().setValueFormatter(mcd.getChartsData().getXValueFormatter()); mActivityChart.getAxisLeft().setDrawLabels(false); mActivityChart.setData(mcd.getChartsData().getData()); heartRateMin = mcd.getHeartRateAxisMin(); heartRateMax = mcd.getHeartRateAxisMax(); intensityTotal = mcd.getIntensityTotal(); lowestHrText.setText(String.valueOf(heartRateMin != 0 ? heartRateMin : "-")); highestHrText.setText(String.valueOf(heartRateMax != 0 ? heartRateMax : "-")); movementIntensityText.setText(intensityTotal > 0 ? new DecimalFormat("###.#").format(intensityTotal) : "-"); movementIntensityTextWrapper.setVisibility(intensityTotal > 0 ? View.VISIBLE : View.GONE); dummyTile.setVisibility(intensityTotal > 0 ? View.VISIBLE : View.GONE); if (!CHARTS_SLEEP_RANGE_24H && supportsHeartrate(getChartsHost().getDevice()) && SHOW_CHARTS_AVERAGE) { if (mcd.getHeartRateAxisMax() != 0 || mcd.getHeartRateAxisMin() != 0) { mActivityChart.getAxisRight().setAxisMaximum(mcd.getHeartRateAxisMax() + (mcd.getHeartRateAxisMin() / 2f)); mActivityChart.getAxisRight().setAxisMinimum(mcd.getHeartRateAxisMin() / 2f); } LimitLine hrAverage_line = new LimitLine(mcd.getHeartRateAverage()); hrAverage_line.setLineColor(Color.RED); hrAverage_line.setLineWidth(1.5f); hrAverage_line.enableDashedLine(15f, 10f, 0f); mActivityChart.getAxisRight().removeAllLimitLines(); mActivityChart.getAxisRight().addLimitLine(hrAverage_line); } } private Triple calculateHrData(List samples) { if (samples.toArray().length < 1) { return Triple.of(0f, 0, 0); } List heartRateValues = new ArrayList<>(); HeartRateUtils heartRateUtilsInstance = HeartRateUtils.getInstance(); for (ActivitySample sample : samples) { if (sample.getKind() == ActivityKind.LIGHT_SLEEP || sample.getKind() == ActivityKind.DEEP_SLEEP) { int heartRate = sample.getHeartRate(); if (heartRateUtilsInstance.isValidHeartRateValue(heartRate)) { heartRateValues.add(heartRate); } } } if (heartRateValues.toArray().length < 1) { return Triple.of(0f, 0, 0); } int min = Collections.min(heartRateValues); int max = Collections.max(heartRateValues); int count = heartRateValues.toArray().length; float sum = calculateSumOfInts(heartRateValues); float average = sum / count; return Triple.of(average, min, max); } private float calculateIntensitySum(List samples) { float result = 0; for (Float sample : samples) { result += sample; } return result; } private float calculateSumOfInts(List samples) { float result = 0; for (Integer sample : samples) { result += sample; } return result; } private Triple calculateIntensityData(List samples) { if (samples.toArray().length < 1) { return Triple.of(0f, 0f, 0f); } List allIntensities = new ArrayList<>(); for (ActivitySample s : samples) { if (s.getKind() == ActivityKind.LIGHT_SLEEP || s.getKind() == ActivityKind.DEEP_SLEEP) { float intensity = s.getIntensity(); allIntensities.add(intensity); } } if (allIntensities.toArray().length < 1) { return Triple.of(0f, 0f, 0f); } Float min = Collections.min(allIntensities); Float max = Collections.max(allIntensities); Float sum = calculateIntensitySum(allIntensities); return Triple.of(sum, min, max); } private String buildYouSleptText(MySleepChartsData pieData) { final StringBuilder result = new StringBuilder(); if (!pieData.getSleepSessions().isEmpty()) { for (SleepSession sleepSession : pieData.getSleepSessions()) { if (result.length() > 0) { result.append(" | "); } String from = DateTimeUtils.timeToString(sleepSession.getSleepStart()); String to = DateTimeUtils.timeToString(sleepSession.getSleepEnd()); result.append(String.format("%s - %s", from, to)); } } return result.toString(); } public boolean supportsSleepScore() { final GBDevice device = getChartsHost().getDevice(); return device.getDeviceCoordinator().supportsSleepScore(); } @Override public String getTitle() { return getString(R.string.sleepchart_your_sleep); } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View rootView = inflater.inflate(R.layout.fragment_sleepchart, container, false); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { rootView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> { getChartsHost().enableSwipeRefresh(scrollY == 0); }); } mActivityChart = rootView.findViewById(R.id.sleepchart); sleepStagesGauge = rootView.findViewById(R.id.sleep_stages_gauge); mSleepchartInfo = rootView.findViewById(R.id.sleepchart_info); remSleepTimeText = rootView.findViewById(R.id.sleep_chart_legend_rem_time); remSleepTimeTextWrapper = rootView.findViewById(R.id.sleep_chart_legend_rem_time_wrapper); awakeSleepTimeText = rootView.findViewById(R.id.sleep_chart_legend_awake_time); awakeSleepTimeTextWrapper = rootView.findViewById(R.id.sleep_chart_legend_awake_time_wrapper); deepSleepTimeText = rootView.findViewById(R.id.sleep_chart_legend_deep_time); lightSleepTimeText = rootView.findViewById(R.id.sleep_chart_legend_light_time); lowestHrText = rootView.findViewById(R.id.sleep_hr_lowest); highestHrText = rootView.findViewById(R.id.sleep_hr_highest); movementIntensityText = rootView.findViewById(R.id.sleep_movement_intensity); movementIntensityTextWrapper = rootView.findViewById(R.id.sleep_chart_legend_movement_intensity_wrapper); dummyTile = rootView.findViewById(R.id.sleep_chart_legend_dummy_tile); sleepDateText = rootView.findViewById(R.id.sleep_date); mSleepchartInfo.setMaxLines(sleepLinesLimit); setupActivityChart(); // refresh immediately instead of use refreshIfVisible(), for perceived performance refresh(); return rootView; } @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (action.equals(ChartsHost.REFRESH)) { // TODO: use LimitLines to visualize smart alarms? mSmartAlarmFrom = intent.getIntExtra("smartalarm_from", -1); mSmartAlarmTo = intent.getIntExtra("smartalarm_to", -1); mTimestampFrom = intent.getIntExtra("recording_base_timestamp", -1); mSmartAlarmGoneOff = intent.getIntExtra("alarm_gone_off", -1); refresh(); } else { super.onReceive(context, intent); } } private void setupActivityChart() { mActivityChart.setBackgroundColor(BACKGROUND_COLOR); mActivityChart.getDescription().setTextColor(DESCRIPTION_COLOR); configureBarLineChartDefaults(mActivityChart); XAxis x = mActivityChart.getXAxis(); x.setDrawLabels(true); x.setDrawGridLines(false); x.setEnabled(true); x.setTextColor(CHART_TEXT_COLOR); x.setDrawLimitLinesBehindData(true); YAxis y = mActivityChart.getAxisLeft(); y.setDrawGridLines(false); y.setAxisMaximum(1f); y.setAxisMinimum(0); y.setDrawTopYLabelEntry(false); y.setTextColor(CHART_TEXT_COLOR); y.setEnabled(true); YAxis yAxisRight = mActivityChart.getAxisRight(); yAxisRight.setDrawGridLines(false); yAxisRight.setEnabled(supportsHeartrate(getChartsHost().getDevice())); yAxisRight.setDrawLabels(true); yAxisRight.setDrawTopYLabelEntry(true); yAxisRight.setTextColor(CHART_TEXT_COLOR); yAxisRight.setAxisMaximum(HeartRateUtils.getInstance().getMaxHeartRate()); yAxisRight.setAxisMinimum(HeartRateUtils.getInstance().getMinHeartRate()); } @Override protected void setupLegend(Chart chart) { List legendEntries = new ArrayList<>(4); LegendEntry lightSleepEntry = new LegendEntry(); lightSleepEntry.label = getActivity().getString(R.string.sleep_colored_stats_light); lightSleepEntry.formColor = akLightSleep.color; legendEntries.add(lightSleepEntry); LegendEntry deepSleepEntry = new LegendEntry(); deepSleepEntry.label = getActivity().getString(R.string.sleep_colored_stats_deep); deepSleepEntry.formColor = akDeepSleep.color; legendEntries.add(deepSleepEntry); if (supportsRemSleep(getChartsHost().getDevice())) { LegendEntry remSleepEntry = new LegendEntry(); remSleepEntry.label = getActivity().getString(R.string.sleep_colored_stats_rem); remSleepEntry.formColor = akRemSleep.color; legendEntries.add(remSleepEntry); } if (supportsAwakeSleep(getChartsHost().getDevice())) { LegendEntry awakeSleepEntry = new LegendEntry(); awakeSleepEntry.label = getActivity().getString(R.string.abstract_chart_fragment_kind_awake_sleep); awakeSleepEntry.formColor = akAwakeSleep.color; legendEntries.add(awakeSleepEntry); } if (supportsHeartrate(getChartsHost().getDevice())) { LegendEntry hrEntry = new LegendEntry(); hrEntry.label = HEARTRATE_LABEL; hrEntry.formColor = HEARTRATE_COLOR; legendEntries.add(hrEntry); if (!CHARTS_SLEEP_RANGE_24H && SHOW_CHARTS_AVERAGE) { LegendEntry hrAverageEntry = new LegendEntry(); hrAverageEntry.label = HEARTRATE_AVERAGE_LABEL; hrAverageEntry.formColor = Color.RED; legendEntries.add(hrAverageEntry); } } chart.getLegend().setCustom(legendEntries); chart.getLegend().setTextColor(LEGEND_TEXT_COLOR); } @Override protected List getSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) { // Temporary fix for totally wrong sleep amounts. return super.getAllSamples(db, device, tsFrom, tsTo); } protected List getSleepScoreSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) { TimeSampleProvider provider = device.getDeviceCoordinator().getSleepScoreProvider(device, db.getDaoSession()); return (List) provider.getAllSamples(tsFrom * 1000L, tsTo * 1000L); } @Override protected void renderCharts() { mActivityChart.animateX(ANIM_TIME, Easing.EaseInOutQuart); } private static class MySleepChartsData extends ChartsData { private long totalSleep; private long totalAwake; private long totalRem; private long totalDeep; private long totalLight; private int sleepScore; private final List sleepSessions; public MySleepChartsData(List sleepSessions, long totalSleep, long totalAwake, long totalRem, long totalDeep, long totalLight, int sleepScore) { this.sleepSessions = sleepSessions; this.totalAwake = totalAwake; this.totalSleep = totalSleep; this.totalRem = totalRem; this.totalDeep = totalDeep; this.totalLight = totalLight; this.sleepScore = sleepScore; } public long getTotalSleep() { return totalSleep; } public long getTotalAwake() { return totalAwake; } public long getTotalRem() { return totalRem; } public long getTotalDeep() { return totalDeep; } public long getTotalLight() { return totalLight; } public int getSleepScore() {return sleepScore;} public List getSleepSessions() { return sleepSessions; } } protected static class MyChartsData extends ChartsData { private final DefaultChartsData chartsData; private final MySleepChartsData pieData; private final float heartRateAverage; private int heartRateAxisMax; private int heartRateAxisMin; private float intensityAxisMax; private float intensityAxisMin; private float intensityTotal; public MyChartsData(MySleepChartsData pieData, DefaultChartsData chartsData, float heartRateAverage, int heartRateAxisMin, int heartRateAxisMax, float intensityTotal, float intensityAxisMin, float intensityAxisMax) { this.pieData = pieData; this.chartsData = chartsData; this.heartRateAverage = heartRateAverage; this.heartRateAxisMax = heartRateAxisMax; this.heartRateAxisMin = heartRateAxisMin; this.intensityTotal = intensityTotal; this.intensityAxisMin = intensityAxisMin; this.intensityAxisMax = intensityAxisMax; } public MySleepChartsData getPieData() { return pieData; } public DefaultChartsData getChartsData() { return chartsData; } public float getHeartRateAverage() { return heartRateAverage; } public int getHeartRateAxisMax() { return heartRateAxisMax; } public int getHeartRateAxisMin() { return heartRateAxisMin; } public float getIntensityAxisMax() { return intensityAxisMax; } public float getIntensityAxisMin() { return intensityAxisMin; } public float getIntensityTotal() { return intensityTotal;} } }