From 7bd3b3c6ff479006c6563923d07c0a323a42ed5a Mon Sep 17 00:00:00 2001 From: Me7c7 Date: Wed, 3 Sep 2025 16:07:15 +0300 Subject: [PATCH] charts: spo2 and temperature overlays added --- .../charts/AbstractActivityChartFragment.java | 35 --- .../activities/charts/SleepDailyFragment.java | 208 +++++++++++++++-- .../charts/sleep/AbstractOverlayData.java | 62 ++++++ .../charts/sleep/OverlayDataFloat.java | 91 ++++++++ .../charts/sleep/OverlayDataInt.java | 92 ++++++++ .../sleep/SimpleSleepDetailsHROverlay.java | 209 ------------------ .../sleep/SimpleSleepDetailsOverlay.java | 185 ++++++++++++++++ .../charts/sleep/SleepDetailsOverlay.java | 4 +- .../charts/sleep/SleepDetailsView.java | 21 +- .../main/res/layout/fragment_sleepchart.xml | 5 +- 10 files changed, 634 insertions(+), 278 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/AbstractOverlayData.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/OverlayDataFloat.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/OverlayDataInt.java delete mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/SimpleSleepDetailsHROverlay.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/SimpleSleepDetailsOverlay.java diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractActivityChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractActivityChartFragment.java index 03ed1adad6..7ec1e8f65a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractActivityChartFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractActivityChartFragment.java @@ -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 extend return result; } - public int[] prepareHR(List 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; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SleepDailyFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SleepDailyFragment.java index ef4641fcc5..77e68eb738 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SleepDailyFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/SleepDailyFragment.java @@ -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 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 provider = (TimeSampleProvider) coordinator.getTemperatureSampleProvider(device, db.getDaoSession()); + + List 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 provider = (TimeSampleProvider) coordinator.getSpo2SampleProvider(device, db.getDaoSession()); + + List 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 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 samples, List sleepScoreSamples) { SleepAnalysis sleepAnalysis = new SleepAnalysis(); List sleepSessions = sleepAnalysis.calculateSleepSessions(samples); @@ -242,11 +359,8 @@ public class SleepDailyFragment extends SleepFragment 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 stages; - private final int[] hr; + private final AbstractOverlayData overlayData; - public MyChartsData(MySleepChartsData pieData, DefaultChartsData chartsData, float heartRateAverage, int heartRateAxisMin, int heartRateAxisMax, float intensityTotal, float intensityAxisMin, float intensityAxisMax, List stages, int[] hr) { + public MyChartsData(MySleepChartsData pieData, DefaultChartsData chartsData, float heartRateAverage, int heartRateAxisMin, int heartRateAxisMax, float intensityTotal, float intensityAxisMin, float intensityAxisMax, List stages, AbstractOverlayData overlayData) { this.pieData = pieData; this.chartsData = chartsData; this.heartRateAverage = heartRateAverage; @@ -592,7 +760,7 @@ public class SleepDailyFragment extends SleepFragment 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; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/OverlayDataFloat.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/OverlayDataFloat.java new file mode 100644 index 0000000000..99a41f870a --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/OverlayDataFloat.java @@ -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); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/OverlayDataInt.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/OverlayDataInt.java new file mode 100644 index 0000000000..37b668d8b7 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/OverlayDataInt.java @@ -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); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/SimpleSleepDetailsHROverlay.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/SimpleSleepDetailsHROverlay.java deleted file mode 100644 index e6a3ea5f34..0000000000 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/SimpleSleepDetailsHROverlay.java +++ /dev/null @@ -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 . */ -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); - } - } -} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/SimpleSleepDetailsOverlay.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/SimpleSleepDetailsOverlay.java new file mode 100644 index 0000000000..bc82a27f4b --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/SimpleSleepDetailsOverlay.java @@ -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 . */ +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); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/SleepDetailsOverlay.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/SleepDetailsOverlay.java index c7637ebabc..90160931e5 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/SleepDetailsOverlay.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/SleepDetailsOverlay.java @@ -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); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/SleepDetailsView.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/SleepDetailsView.java index 2573b73585..29ea396b55 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/SleepDetailsView.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/sleep/SleepDetailsView.java @@ -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 sleepDetails) { + public void setData(List 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(); diff --git a/app/src/main/res/layout/fragment_sleepchart.xml b/app/src/main/res/layout/fragment_sleepchart.xml index 0236838db2..575f5ad1d9 100644 --- a/app/src/main/res/layout/fragment_sleepchart.xml +++ b/app/src/main/res/layout/fragment_sleepchart.xml @@ -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" /> + android:layout_marginEnd="10dp" + android:layout_marginBottom="10dp"/>