charts: spo2 and temperature overlays added

This commit is contained in:
Me7c7
2025-09-07 12:15:36 +02:00
committed by José Rebelo
parent 06925b2384
commit 7bd3b3c6ff
10 changed files with 634 additions and 278 deletions
@@ -39,7 +39,6 @@ import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.HeartRateUtils;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.sleep.SimpleSleepDetailsHROverlay;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.sleep.SleepDetailsView;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
@@ -345,40 +344,6 @@ public abstract class AbstractActivityChartFragment<D extends ChartsData> extend
return result;
}
public int[] prepareHR(List<? extends ActivitySample> samples) {
if (samples.isEmpty()) {
return null;
}
//NOTE: The start and end times of the samples should correspond to the sleep stages.
// The time interval between samples is not important (it can be in minutes, seconds, etc.), but all intervals must be filled.
// If there is a gap in the raw data, 'SimpleSleepDetailsHROverlay.NO_DATA' should be used.
// As we exactly know the intervals, we can use primitive types (which have better performance during drawing).
// Samples already prepared in minute's interval can simply be copied with filtering.
TimestampTranslation tsTranslation = new TimestampTranslation();
HeartRateUtils heartRateUtilsInstance = HeartRateUtils.getInstance();
int[] result = new int[samples.size()];
int idx = 0;
int lastTsShorten = 0;
for (ActivitySample sample : samples) {
if (sample.getKind() == ActivityKind.NOT_WORN || !heartRateUtilsInstance.isValidHeartRateValue(sample.getHeartRate())) {
result[idx++] = -1;
continue;
}
int tsShorten = tsTranslation.shorten(sample.getTimestamp());
if (lastTsShorten == 0 || (tsShorten - lastTsShorten) <= 60 * HeartRateUtils.MAX_HR_MEASUREMENTS_GAP_MINUTES) {
result[idx++] = sample.getHeartRate();
} else {
result[idx++] = SimpleSleepDetailsHROverlay.NO_DATA;
}
lastTsShorten = tsShorten;
}
return result;
}
protected int getIndexOfActivity(ActivityKind kind) {
return switch (kind) {
case DEEP_SLEEP -> 0;
@@ -27,7 +27,6 @@ import android.util.TypedValue;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CompoundButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
@@ -50,6 +49,7 @@ import org.slf4j.LoggerFactory;
import java.text.DecimalFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.Iterator;
@@ -60,14 +60,22 @@ 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.charts.sleep.SimpleSleepDetailsHROverlay;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.sleep.AbstractOverlayData;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.sleep.OverlayDataFloat;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.sleep.OverlayDataInt;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.sleep.SimpleSleepDetailsOverlay;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.sleep.SleepDetailsOverlay;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.sleep.SleepDetailsView;
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.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.SleepScoreSample;
import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
import nodomain.freeyourgadget.gadgetbridge.model.TemperatureSample;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
@@ -144,10 +152,119 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
List<SleepDetailsView.SleepDetail> stages = prepareStages(samples);
int[] hr = hrOverlay?prepareHR(samples):null;
return new MyChartsData(mySleepChartsData, chartsData, hrData.getLeft(), hrData.getMiddle(), hrData.getRight(), intensityData.getLeft(), intensityData.getMiddle(), intensityData.getRight(), stages, hr);
AbstractOverlayData overlay = null;
if(currentOverlay == OverlayType.HEART_RATE) {
int average = SHOW_CHARTS_AVERAGE?Math.round(hrData.getLeft()):OverlayDataInt.NO_DATA;
overlay = new OverlayDataInt(20, 140, prepareHR(samples), average, HEARTRATE_COLOR, Color.RED);
} else if(currentOverlay == OverlayType.SPO2) {
overlay = new OverlayDataInt(68, 100, prepareSpO2Overlay(db, device, samples.get(0).getTimestamp() * 1000L, samples.get(samples.size() - 1).getTimestamp() * 1000L), OverlayDataInt.NO_DATA, getResources().getColor(R.color.spo2_color), Color.RED);
} else if(currentOverlay == OverlayType.TEMPERATURE) {
overlay = new OverlayDataFloat(28, 45, prepareTemperature(db, device, samples.get(0).getTimestamp() * 1000L, samples.get(samples.size() - 1).getTimestamp() * 1000L), OverlayDataFloat.NO_DATA, CHART_TEXT_COLOR, Color.RED);
}
return new MyChartsData(mySleepChartsData, chartsData, hrData.getLeft(), hrData.getMiddle(), hrData.getRight(), intensityData.getLeft(), intensityData.getMiddle(), intensityData.getRight(), stages, overlay);
}
public float[] prepareTemperature(DBHandler db, GBDevice device, long tsStart, long tsEnd) {
DeviceCoordinator coordinator = device.getDeviceCoordinator();
TimeSampleProvider<TemperatureSample> provider = (TimeSampleProvider<TemperatureSample>) coordinator.getTemperatureSampleProvider(device, db.getDaoSession());
List<TemperatureSample> samples = provider.getAllSamples(tsStart, tsEnd);
if(samples.isEmpty())
return null;
long interval = Long.MAX_VALUE;
for(int i = 1; i < samples.size(); i++) {
long delta = samples.get(i).getTimestamp() - samples.get(i - 1).getTimestamp();
if(delta < interval) {
interval = delta;
}
}
int count = (int)((float)(tsEnd - tsStart) / interval);
if(count == 0)
return null;
float[] result = new float[count];
Arrays.fill(result, -1);
for(TemperatureSample sp: samples) {
int idx = (int)((float)(sp.getTimestamp() - tsStart) / interval);
if(idx > 0 && idx < count) {
result[idx] = sp.getTemperature();
}
}
return result;
}
public int[] prepareSpO2Overlay(DBHandler db, GBDevice device, long tsStart, long tsEnd) {
DeviceCoordinator coordinator = device.getDeviceCoordinator();
TimeSampleProvider<Spo2Sample> provider = (TimeSampleProvider<Spo2Sample>) coordinator.getSpo2SampleProvider(device, db.getDaoSession());
List<Spo2Sample> samples = provider.getAllSamples(tsStart, tsEnd);
if(samples.isEmpty())
return null;
long interval = Long.MAX_VALUE;
for(int i = 1; i < samples.size(); i++) {
long delta = samples.get(i).getTimestamp() - samples.get(i - 1).getTimestamp();
if(delta < interval) {
interval = delta;
}
}
int count = (int)((float)(tsEnd - tsStart) / interval);
if(count == 0)
return null;
int[] result = new int[count];
Arrays.fill(result, -1);
for(Spo2Sample sp: samples) {
int idx = (int)((float)(sp.getTimestamp() - tsStart) / interval);
if(idx > 0 && idx < count) {
result[idx] = sp.getSpo2();
}
}
return result;
}
public int[] prepareHR(List<? extends ActivitySample> samples) {
if (samples.isEmpty()) {
return null;
}
//NOTE: The start and end times of the samples should correspond to the sleep stages.
// The time interval between samples is not important (it can be in minutes, seconds, etc.), but all intervals must be filled.
// If there is a gap in the raw data, 'SimpleSleepDetailsHROverlay.NO_DATA' should be used.
// As we exactly know the intervals, we can use primitive types (which have better performance during drawing).
// Samples already prepared in minute's interval can simply be copied with filtering.
TimestampTranslation tsTranslation = new TimestampTranslation();
HeartRateUtils heartRateUtilsInstance = HeartRateUtils.getInstance();
int[] result = new int[samples.size()];
int idx = 0;
int lastTsShorten = 0;
for (ActivitySample sample : samples) {
if (sample.getKind() == ActivityKind.NOT_WORN || !heartRateUtilsInstance.isValidHeartRateValue(sample.getHeartRate())) {
result[idx++] = -1;
continue;
}
int tsShorten = tsTranslation.shorten(sample.getTimestamp());
if (lastTsShorten == 0 || (tsShorten - lastTsShorten) <= 60 * HeartRateUtils.MAX_HR_MEASUREMENTS_GAP_MINUTES) {
result[idx++] = sample.getHeartRate();
} else {
result[idx++] = OverlayDataInt.NO_DATA;
}
lastTsShorten = tsShorten;
}
return result;
}
private MySleepChartsData refreshSleepAmounts(GBDevice mGBDevice, List<? extends ActivitySample> samples, List<? extends SleepScoreSample> sleepScoreSamples) {
SleepAnalysis sleepAnalysis = new SleepAnalysis();
List<SleepSession> sleepSessions = sleepAnalysis.calculateSleepSessions(samples);
@@ -242,11 +359,8 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
MySleepChartsData pieData = mcd.getPieData();
if (mcd.getStages() != null) {
mSleepDetailsView.setData(mcd.getStages());
}
if(mcd.getHr() != null) {
int average = SHOW_CHARTS_AVERAGE?Math.round(mcd.getHeartRateAverage()):SimpleSleepDetailsHROverlay.NO_DATA;
mSleepDetailsView.setOverlay(new SimpleSleepDetailsHROverlay(20, 140, mcd.getHr(), average, HEARTRATE_COLOR, Color.BLACK, Color.RED));
SleepDetailsOverlay overlay = (mcd.getOverlayData() != null)?new SimpleSleepDetailsOverlay(mcd.getOverlayData() , Color.BLACK): null;
mSleepDetailsView.setData(mcd.getStages(), overlay);
}
Date date = new Date((long) this.getTSEnd() * 1000);
@@ -384,7 +498,24 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
return result.toString();
}
private boolean hrOverlay = false;
enum OverlayType {
NONE,
HEART_RATE,
SPO2,
TEMPERATURE,
}
private OverlayType currentOverlay = OverlayType.NONE;
public boolean supportsSpO2(GBDevice device) {
DeviceCoordinator coordinator = device.getDeviceCoordinator();
return coordinator.supportsSpo2(device);
}
public boolean supportsTemperature(GBDevice device) {
DeviceCoordinator coordinator = device.getDeviceCoordinator();
return coordinator.supportsTemperatureMeasurement(device);
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
@@ -415,18 +546,55 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
ChipGroup chipGroup = rootView.findViewById(R.id.sleep_chart_overlay_group);
chipGroup.setOnCheckedStateChangeListener(new ChipGroup.OnCheckedStateChangeListener() {
@Override
public void onCheckedChanged(@NonNull ChipGroup chipGroup, @NonNull List<Integer> list) {
if(list.isEmpty()) {
currentOverlay = OverlayType.NONE;
refresh();
}
}
});
if(supportsHeartrate(getChartsHost().getDevice())) {
Chip hrChip = (Chip) inflater.inflate(R.layout.layout_chart_chip, chipGroup, false);
hrChip.setText(ContextCompat.getString(GBApplication.getContext(), R.string.heart_rate));
hrChip.setChipIconVisible(true);
hrChip.setChipIconResource(R.drawable.ic_heartrate);
chipGroup.addView(hrChip);
hrChip.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(@NonNull CompoundButton buttonView, boolean isChecked) {
hrOverlay = isChecked;
refresh();
hrChip.setOnCheckedChangeListener((buttonView, isChecked) -> {
if(isChecked) {
currentOverlay = OverlayType.HEART_RATE;
}
refresh();
});
}
if(supportsSpO2(getChartsHost().getDevice())) {
Chip spo2Chip = (Chip) inflater.inflate(R.layout.layout_chart_chip, chipGroup, false);
spo2Chip.setText(ContextCompat.getString(GBApplication.getContext(), R.string.menuitem_spo2));
spo2Chip.setChipIconVisible(true);
spo2Chip.setChipIconResource(R.drawable.ic_spo2);
chipGroup.addView(spo2Chip);
spo2Chip.setOnCheckedChangeListener((buttonView, isChecked) -> {
if(isChecked) {
currentOverlay = OverlayType.SPO2;
}
refresh();
});
}
if(supportsTemperature(getChartsHost().getDevice())) {
Chip tempChip = (Chip) inflater.inflate(R.layout.layout_chart_chip, chipGroup, false);
tempChip.setText(ContextCompat.getString(GBApplication.getContext(), R.string.menuitem_temperature));
tempChip.setChipIconVisible(true);
tempChip.setChipIconResource(R.drawable.ic_temperature);
chipGroup.addView(tempChip);
tempChip.setOnCheckedChangeListener((buttonView, isChecked) -> {
if(isChecked) {
currentOverlay = OverlayType.TEMPERATURE;
}
refresh();
});
}
@@ -580,9 +748,9 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
private final List<SleepDetailsView.SleepDetail> stages;
private final int[] hr;
private final AbstractOverlayData overlayData;
public MyChartsData(MySleepChartsData pieData, DefaultChartsData<LineData> chartsData, float heartRateAverage, int heartRateAxisMin, int heartRateAxisMax, float intensityTotal, float intensityAxisMin, float intensityAxisMax, List<SleepDetailsView.SleepDetail> stages, int[] hr) {
public MyChartsData(MySleepChartsData pieData, DefaultChartsData<LineData> chartsData, float heartRateAverage, int heartRateAxisMin, int heartRateAxisMax, float intensityTotal, float intensityAxisMin, float intensityAxisMax, List<SleepDetailsView.SleepDetail> stages, AbstractOverlayData overlayData) {
this.pieData = pieData;
this.chartsData = chartsData;
this.heartRateAverage = heartRateAverage;
@@ -592,7 +760,7 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
this.intensityAxisMin = intensityAxisMin;
this.intensityAxisMax = intensityAxisMax;
this.stages = stages;
this.hr = hr;
this.overlayData = overlayData;
}
public MySleepChartsData getPieData() {
@@ -631,8 +799,8 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
return stages;
}
public int[] getHr() {
return hr;
public AbstractOverlayData getOverlayData() {
return overlayData;
}
}
}
@@ -0,0 +1,62 @@
package nodomain.freeyourgadget.gadgetbridge.activities.charts.sleep;
public abstract class AbstractOverlayData {
private final float yMin;
private final float yMax;
private final float yDelta;
public AbstractOverlayData(float yMin, float yMax) {
this.yMin = yMin;
this.yMax = yMax;
this.yDelta = yMax - yMin;
}
abstract boolean isData(int i);
abstract boolean isMeasured(int i);
abstract int getMainColor();
abstract int getAverageColor();
public float getYDelta() {
return yDelta;
}
abstract int getLength();
protected float getAdjustedValueInternal(float val) {
if(val < yMin) {
return 0;
}
if(val > yMax) {
return yDelta;
}
return val - yMin;
}
abstract float getAdjustedValue(int i);
abstract boolean hasAverage();
abstract float getAdjustedAverageValue();
abstract String getCurrentValue(int i);
abstract float adjustYLabelDelta(float delta);
abstract String getYLabelValue(float val);
String[] getYLabels(int horizontalLineCount) {
float yLabelsDelta = adjustYLabelDelta(yDelta / horizontalLineCount);
String[] res = new String[horizontalLineCount + 1];
float yStart = yMax;
for (int i = 0; i <= horizontalLineCount; i++) {
res[i] = getYLabelValue(yStart);
yStart -= yLabelsDelta;
}
return res;
}
}
@@ -0,0 +1,91 @@
package nodomain.freeyourgadget.gadgetbridge.activities.charts.sleep;
import java.util.Locale;
public class OverlayDataFloat extends AbstractOverlayData{
public static float NO_DATA = Float.MIN_VALUE;
private final float average;
private final float[] data;
private final int mainColor;
private final int averageColor;
public OverlayDataFloat(float yMin, float yMax, float[] data, float average, int mainColor, int averageColor) {
super(yMin, yMax);
this.average = average;
this.data = data;
this.mainColor = mainColor;
this.averageColor = averageColor;
}
@Override
public boolean isData(int i) {
if(data == null || i < 0 || i > data.length) {
return false;
}
return this.data[i] != NO_DATA;
}
@Override
public boolean isMeasured(int i) {
if(data == null || i < 0 || i > data.length) {
return false;
}
return this.data[i] >= 0;
}
@Override
public int getMainColor() {
return mainColor;
}
@Override
public int getAverageColor() {
return averageColor;
}
@Override
public int getLength() {
if(data == null) {
return 0;
}
return data.length;
}
@Override
public float getAdjustedValue(int i) {
if(data == null || i < 0 || i > data.length) {
return 0;
}
return getAdjustedValueInternal(this.data[i]);
}
@Override
public boolean hasAverage() {
return average != NO_DATA;
}
@Override
public float getAdjustedAverageValue() {
return getAdjustedValueInternal(average);
}
@Override
public String getCurrentValue(int i) {
return String.format(Locale.ROOT, "%.2f", this.data[i]);
}
@Override
public float adjustYLabelDelta(float delta) {
return delta;
}
@Override
public String getYLabelValue(float val) {
return String.format(Locale.ROOT, "%.2f", val);
}
}
@@ -0,0 +1,92 @@
package nodomain.freeyourgadget.gadgetbridge.activities.charts.sleep;
import java.util.Locale;
public class OverlayDataInt extends AbstractOverlayData{
public static int NO_DATA = Integer.MIN_VALUE;
private final int average;
private final int[] data;
private final int mainColor;
private final int averageColor;
public OverlayDataInt(int yMin, int yMax, int[] data, int average, int mainColor, int averageColor) {
super(yMin, yMax);
this.average = average;
this.data = data;
this.mainColor = mainColor;
this.averageColor = averageColor;
}
@Override
public boolean isData(int i) {
if(data == null || i < 0 || i > data.length) {
return false;
}
return this.data[i] != NO_DATA;
}
@Override
public boolean isMeasured(int i) {
if(data == null || i < 0 || i > data.length) {
return false;
}
return this.data[i] >= 0;
}
@Override
public int getMainColor() {
return mainColor;
}
@Override
public int getAverageColor() {
return averageColor;
}
@Override
public int getLength() {
if(data == null) {
return 0;
}
return data.length;
}
@Override
public float getAdjustedValue(int i) {
if(data == null || i < 0 || i > data.length) {
return 0;
}
return getAdjustedValueInternal(this.data[i]);
}
@Override
public boolean hasAverage() {
return average != NO_DATA;
}
@Override
public float getAdjustedAverageValue() {
return getAdjustedValueInternal(average);
}
@Override
public String getCurrentValue(int i) {
return String.format(Locale.ROOT, "%d", this.data[i]);
}
@Override
public float adjustYLabelDelta(float delta) {
return (int)delta;
}
@Override
public String getYLabelValue(float val) {
return String.format(Locale.ROOT, "%d", (int)val);
}
}
@@ -1,209 +0,0 @@
/* Copyright (C) 2025 Me7c7
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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.charts.sleep;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.DashPathEffect;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.Rect;
import java.util.Locale;
public class SimpleSleepDetailsHROverlay implements SleepDetailsOverlay {
public static int NO_DATA = Integer.MIN_VALUE;
private final int[] data;
private final int average;
private final int yMin;
private final int yMax;
private final int yDelta;
private final Path linePath = new Path();
private final Paint linePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint averagePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint dotPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint hudPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint hudBackgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Rect labelRect = new Rect();
private final Paint scalePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
public SimpleSleepDetailsHROverlay(int yMin, int yMax, int[] data, int average, int mainColor, int hubBackgroundColor, int averageColor) {
linePaint.setStyle(Paint.Style.STROKE);
linePaint.setStrokeWidth(4);
linePaint.setColor(mainColor);
dotPaint.setStyle(Paint.Style.FILL);
dotPaint.setColor(mainColor);
hudPaint.setStyle(Paint.Style.STROKE);
hudPaint.setStrokeWidth(2);
hudPaint.setColor(mainColor);
hudBackgroundPaint.setStyle(Paint.Style.FILL);
hudBackgroundPaint.setColor(hubBackgroundColor);
labelPaint.setColor(Color.GRAY);
labelPaint.setTextAlign(Paint.Align.CENTER);
labelPaint.setTextSize(25);
scalePaint.setColor(Color.GRAY);
scalePaint.setTextAlign(Paint.Align.RIGHT);
scalePaint.setTextSize(25);
averagePaint.setColor(averageColor);
averagePaint.setStrokeWidth(2);
averagePaint.setStrokeCap(Paint.Cap.ROUND);
averagePaint.setStyle(Paint.Style.STROKE);
averagePaint.setPathEffect(new DashPathEffect(new float[]{15f, 5f}, 0f));
this.data = data;
this.yMax = yMax;
this.yMin = yMin;
this.average = average;
this.yDelta = this.yMax - this.yMin;
}
private float getAdjustedValue(float val) {
if(val < yMin) {
return 0;
}
if(val > yMax) {
return yDelta;
}
return val - yMin;
}
@Override
public void drawOverlayScale(Canvas canvas, int lineSpacing, int horizontalLineCount, int chartTopStart, int x) {
int yLabelsDelta = yDelta / horizontalLineCount;
int yStart = yMax;
for (int i = 0; i <= horizontalLineCount; i++) {
final float y = (i * lineSpacing);
final String currentValue = String.format(Locale.ROOT, "%d", yStart);
canvas.drawText(currentValue, x, chartTopStart + y, scalePaint);
yStart -= yLabelsDelta;
}
}
@Override
public void drawOverlay(Canvas canvas, int left, int top, int height, int width) {
if (data == null || data.length == 0)
return;
float unitHeight = ((float) height / yDelta);
float unitWidth = ((float) width / data.length);
linePath.reset();
boolean first = true;
float prevX = 0;
float prevY = 0;
for (int i = 0; i < data.length; i++) {
if(data[i] == NO_DATA) {
if(!first) {
canvas.drawPath(linePath, linePaint);
linePath.reset();
}
first = true;
}
if (data[i] < 0) {
continue;
}
float curX = left + i * unitWidth;
float curY = top + (height - (getAdjustedValue(data[i]) * unitHeight));
if (first) {
linePath.moveTo(curX, curY);
first = false;
} else {
float conX1, conY1, conX2, conY2;
conX1 = (prevX + curX) / 2;
conY1 = prevY;
conX2 = (prevX + curX) / 2;
conY2 = curY;
linePath.cubicTo(
conX1, conY1, conX2, conY2,
curX, curY
);
}
prevX = curX;
prevY = curY;
}
canvas.drawPath(linePath, linePaint);
if(average > 0) {
float avgY = top + (height - (getAdjustedValue(average) * unitHeight));
canvas.drawLine(left, avgY, left + width, avgY, averagePaint);
}
}
@Override
public void drawOverlaySelector(Canvas canvas, int left, int top, int height, int width, float selectorPos) {
final float unitWidth = ((float) width / data.length);
int idx = (int) ((selectorPos - left) / unitWidth);
if (idx > 0 && idx < data.length && data[idx] > 0) {
float unitHeight = ((float) height / yDelta);
final int verticalPadding = 12;
final int horizontalPadding = 16;
final float cornerRadius = 10F;
final float lineLength = 15F;
final float dataValue = data[idx];
final float centerX = left + idx * unitWidth;
final float centerY = top + height - (getAdjustedValue(dataValue) * unitHeight);
final String currentValue = String.format(Locale.ROOT, "%d", (int) dataValue);
canvas.drawCircle(centerX, centerY, 7, dotPaint);
labelPaint.getTextBounds(currentValue, 0, currentValue.length(), labelRect);
float bgLeft = centerX - (labelRect.width() / 2f) - horizontalPadding;
float bgBottom = centerY - lineLength - verticalPadding;
float bgTop = bgBottom - labelRect.height() - 2 * verticalPadding;
float bgRight = centerX + (labelRect.width() / 2f) + horizontalPadding;
if (bgLeft < left + verticalPadding) {
bgLeft = left + verticalPadding;
bgRight = bgLeft + labelRect.width() + 2 * horizontalPadding;
}
if (bgRight > left + width - verticalPadding) {
bgRight = left + width - verticalPadding;
bgLeft = bgRight - (labelRect.width() + 2 * horizontalPadding);
}
float adjustedCenterX = (bgLeft + bgRight) / 2f;
canvas.drawLine(centerX, centerY, adjustedCenterX, bgBottom, dotPaint);
canvas.drawRoundRect(bgLeft, bgTop, bgRight, bgBottom, cornerRadius, cornerRadius, hudBackgroundPaint);
canvas.drawRoundRect(bgLeft, bgTop, bgRight, bgBottom, cornerRadius, cornerRadius, hudPaint);
canvas.drawText(currentValue, adjustedCenterX, bgBottom - verticalPadding, labelPaint);
}
}
}
@@ -0,0 +1,185 @@
/* Copyright (C) 2025 Me7c7
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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.charts.sleep;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.DashPathEffect;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.Rect;
public class SimpleSleepDetailsOverlay implements SleepDetailsOverlay {
private final AbstractOverlayData data;
private int horizontalLineCount = 8;
private String[] labels = new String[horizontalLineCount + 1];
private final Path linePath = new Path();
private final Paint linePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint averagePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint dotPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint hudPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint hudBackgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Rect labelRect = new Rect();
private final Paint scalePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
public SimpleSleepDetailsOverlay(AbstractOverlayData data, int hubBackgroundColor) {
this.data = data;
linePaint.setStyle(Paint.Style.STROKE);
linePaint.setStrokeWidth(4);
linePaint.setColor(data.getMainColor());
dotPaint.setStyle(Paint.Style.FILL);
dotPaint.setColor(data.getMainColor());
hudPaint.setStyle(Paint.Style.STROKE);
hudPaint.setStrokeWidth(2);
hudPaint.setColor(data.getMainColor());
hudBackgroundPaint.setStyle(Paint.Style.FILL);
hudBackgroundPaint.setColor(hubBackgroundColor);
labelPaint.setColor(Color.GRAY);
labelPaint.setTextAlign(Paint.Align.CENTER);
labelPaint.setTextSize(25);
scalePaint.setColor(Color.GRAY);
scalePaint.setTextAlign(Paint.Align.RIGHT);
scalePaint.setTextSize(25);
averagePaint.setColor(data.getAverageColor());
averagePaint.setStrokeWidth(2);
averagePaint.setStrokeCap(Paint.Cap.ROUND);
averagePaint.setStyle(Paint.Style.STROKE);
averagePaint.setPathEffect(new DashPathEffect(new float[]{15f, 5f}, 0f));
}
@Override
public void init(int horizontalLineCount) {
this.horizontalLineCount = horizontalLineCount;
labels = data.getYLabels(horizontalLineCount);
}
@Override
public void drawOverlayScale(Canvas canvas, int lineSpacing, int chartTopStart, int x) {
for (int i = 0; i <= horizontalLineCount; i++) {
final float y = (i * lineSpacing);
canvas.drawText(labels[i], x, chartTopStart + y, scalePaint);
}
}
@Override
public void drawOverlay(Canvas canvas, int left, int top, int height, int width) {
if (data == null || data.getLength() == 0)
return;
float unitHeight = ((float) height / data.getYDelta());
float unitWidth = ((float) width / data.getLength());
linePath.reset();
boolean first = true;
float prevX = 0;
float prevY = 0;
for (int i = 0; i < data.getLength(); i++) {
if (!data.isData(i)) {
if (!first) {
canvas.drawPath(linePath, linePaint);
linePath.reset();
}
first = true;
}
if (!data.isMeasured(i)) {
continue;
}
float curX = left + i * unitWidth;
float curY = top + (height - (data.getAdjustedValue(i) * unitHeight));
if (first) {
linePath.moveTo(curX, curY);
first = false;
} else {
float conX1, conY1, conX2, conY2;
conX1 = (prevX + curX) / 2;
conY1 = prevY;
conX2 = (prevX + curX) / 2;
conY2 = curY;
linePath.cubicTo(
conX1, conY1, conX2, conY2,
curX, curY
);
}
prevX = curX;
prevY = curY;
}
canvas.drawPath(linePath, linePaint);
if (data.hasAverage()) {
float avgY = top + (height - (data.getAdjustedAverageValue() * unitHeight));
canvas.drawLine(left, avgY, left + width, avgY, averagePaint);
}
}
@Override
public void drawOverlaySelector(Canvas canvas, int left, int top, int height, int width, float selectorPos) {
final float unitWidth = ((float) width / data.getLength());
int idx = (int) ((selectorPos - left) / unitWidth);
if (idx > 0 && idx < data.getLength() && data.isMeasured(idx)) {
float unitHeight = ((float) height / data.getYDelta());
final int verticalPadding = 12;
final int horizontalPadding = 16;
final float cornerRadius = 10F;
final float lineLength = 15F;
final float centerX = left + idx * unitWidth;
final float centerY = top + height - (data.getAdjustedValue(idx) * unitHeight);
final String currentValue = data.getCurrentValue(idx);
canvas.drawCircle(centerX, centerY, 7, dotPaint);
labelPaint.getTextBounds(currentValue, 0, currentValue.length(), labelRect);
float bgLeft = centerX - (labelRect.width() / 2f) - horizontalPadding;
float bgBottom = centerY - lineLength - verticalPadding;
float bgTop = bgBottom - labelRect.height() - 2 * verticalPadding;
float bgRight = centerX + (labelRect.width() / 2f) + horizontalPadding;
if (bgLeft < left + verticalPadding) {
bgLeft = left + verticalPadding;
bgRight = bgLeft + labelRect.width() + 2 * horizontalPadding;
}
if (bgRight > left + width - verticalPadding) {
bgRight = left + width - verticalPadding;
bgLeft = bgRight - (labelRect.width() + 2 * horizontalPadding);
}
float adjustedCenterX = (bgLeft + bgRight) / 2f;
canvas.drawLine(centerX, centerY, adjustedCenterX, bgBottom, dotPaint);
canvas.drawRoundRect(bgLeft, bgTop, bgRight, bgBottom, cornerRadius, cornerRadius, hudBackgroundPaint);
canvas.drawRoundRect(bgLeft, bgTop, bgRight, bgBottom, cornerRadius, cornerRadius, hudPaint);
canvas.drawText(currentValue, adjustedCenterX, bgBottom - verticalPadding, labelPaint);
}
}
}
@@ -20,7 +20,9 @@ import android.graphics.Canvas;
public interface SleepDetailsOverlay {
void drawOverlayScale(Canvas canvas, int lineSpacing, int horizontalLineCount, int chartTopStart, int x);
void init(int horizontalLineCount);
void drawOverlayScale(Canvas canvas, int lineSpacing, int chartTopStart, int x);
void drawOverlay(Canvas canvas, int left, int top, int height, int width);
@@ -184,7 +184,7 @@ public class SleepDetailsView extends View {
canvas.drawLine(chartLeftStart + chartWidth, chartTopStart, chartLeftStart + chartWidth, chartTopStart + chartHeight, gridPaint);
if(curOverlay != null) {
curOverlay.drawOverlayScale(canvas, lineSpacing, horizontalLineCount, chartTopStart, chartLeftStart + chartWidth);
curOverlay.drawOverlayScale(canvas, lineSpacing, chartTopStart, chartLeftStart + chartWidth);
}
}
@@ -432,14 +432,15 @@ public class SleepDetailsView extends View {
//draw selector
if (selectorPos > chartLeftStart && selectorPos < chartLeftStart + chartWidth) {
canvas.drawLine(selectorPos, chartTopStart, selectorPos, chartTopStart + chartHeight, selectorPaint);
if (curOverlay != null) {
curOverlay.drawOverlaySelector(canvas, chartLeftStart, chartTopStart, chartHeight, chartWidth, selectorPos);
}
if (selectorPos > (chartLeftStart + timeInfoTextRect.width()) && selectorPos < (chartLeftStart + chartWidth - timeInfoTextRect.width())) {
final String selectedDate = formatDurationHoursMinutes(this.startTimestamp + (long) ((selectorPos - chartLeftStart) / unitWidth) * 60000L);
canvas.drawText(selectedDate, selectorPos, timeInfoY, timeInfoTextPaint);
}
drawSelectedInfo(canvas);
if (curOverlay != null) {
curOverlay.drawOverlaySelector(canvas, chartLeftStart, chartTopStart, chartHeight, chartWidth, selectorPos);
}
}
}
@@ -449,7 +450,7 @@ public class SleepDetailsView extends View {
heightUnit = (float) (1.0 / (config.length * 2));
}
public void setData(List<SleepDetail> sleepDetails) {
public void setData(List<SleepDetail> sleepDetails, SleepDetailsOverlay overlay) {
curOverlay = null;
this.sleepDetails = sleepDetails;
this.sleepMinutesCount = 0;
@@ -462,15 +463,13 @@ public class SleepDetailsView extends View {
this.endTimestamp = last.startTimestamp + ((last.durationMinutes) * 60000L);
}
selectorPos = -1.0F;
curOverlay = overlay;
if(curOverlay != null) {
curOverlay.init(horizontalLineCount);
}
invalidate();
}
public void setOverlay(SleepDetailsOverlay overlay) {
curOverlay = overlay;
}
@Override
public boolean performClick() {
super.performClick();
@@ -179,7 +179,7 @@
android:layout_marginStart="2dp"
android:layout_marginTop="15dp"
android:layout_marginEnd="2dp"
android:layout_marginBottom="25dp"
android:layout_marginBottom="15dp"
android:layout_weight="4" />
<com.google.android.material.chip.ChipGroup
@@ -189,7 +189,8 @@
android:layout_weight="4"
app:singleSelection="true"
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp"/>
android:layout_marginEnd="10dp"
android:layout_marginBottom="10dp"/>
<GridLayout
android:background="@color/gauge_line_color"