Charts: Simple heart rate overlay to sleep chart added

This commit is contained in:
Me7c7
2025-08-21 21:09:39 +02:00
committed by José Rebelo
parent 1c9290c3c3
commit dee73d3de2
7 changed files with 459 additions and 104 deletions
@@ -39,6 +39,8 @@ 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;
import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
@@ -343,6 +345,40 @@ 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,11 +27,12 @@ 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.ScrollView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import com.github.mikephil.charting.animation.Easing;
@@ -39,6 +40,8 @@ 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 com.google.android.material.chip.Chip;
import com.google.android.material.chip.ChipGroup;
import org.apache.commons.lang3.tuple.Triple;
import org.slf4j.Logger;
@@ -57,6 +60,8 @@ 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.SleepDetailsView;
import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.GaugeDrawer;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
@@ -138,7 +143,9 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
Triple<Float, Float, Float> intensityData = calculateIntensityData(samples);
List<SleepDetailsView.SleepDetail> stages = prepareStages(samples);
return new MyChartsData(mySleepChartsData, chartsData, hrData.getLeft(), hrData.getMiddle(), hrData.getRight(), intensityData.getLeft(), intensityData.getMiddle(), intensityData.getRight(), stages);
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);
}
private MySleepChartsData refreshSleepAmounts(GBDevice mGBDevice, List<? extends ActivitySample> samples, List<? extends SleepScoreSample> sleepScoreSamples) {
@@ -237,6 +244,10 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
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));
}
Date date = new Date((long) this.getTSEnd() * 1000);
String formattedDate = new SimpleDateFormat("E, MMM dd").format(date);
@@ -373,6 +384,7 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
return result.toString();
}
private boolean hrOverlay = false;
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
@@ -401,6 +413,23 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
sleepDateText = rootView.findViewById(R.id.sleep_date);
ChipGroup chipGroup = rootView.findViewById(R.id.sleep_chart_overlay_group);
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();
}
});
}
mSleepchartInfo.setMaxLines(sleepLinesLimit);
setupActivityChart();
@@ -551,7 +580,9 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
private final List<SleepDetailsView.SleepDetail> stages;
public MyChartsData(MySleepChartsData pieData, DefaultChartsData<LineData> chartsData, float heartRateAverage, int heartRateAxisMin, int heartRateAxisMax, float intensityTotal, float intensityAxisMin, float intensityAxisMax, List<SleepDetailsView.SleepDetail> stages) {
private final 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, int[] hr) {
this.pieData = pieData;
this.chartsData = chartsData;
this.heartRateAverage = heartRateAverage;
@@ -561,6 +592,7 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
this.intensityAxisMin = intensityAxisMin;
this.intensityAxisMax = intensityAxisMax;
this.stages = stages;
this.hr = hr;
}
public MySleepChartsData getPieData() {
@@ -598,5 +630,9 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
public List<SleepDetailsView.SleepDetail> getStages() {
return stages;
}
public int[] getHr() {
return hr;
}
}
}
@@ -0,0 +1,209 @@
/* 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,28 @@
/* 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;
public interface SleepDetailsOverlay {
void drawOverlayScale(Canvas canvas, int lineSpacing, int horizontalLineCount, int chartTopStart, int x);
void drawOverlay(Canvas canvas, int left, int top, int height, int width);
void drawOverlaySelector(Canvas canvas, int left, int top, int height, int width, float selectorPos);
}
@@ -14,7 +14,7 @@
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;
package nodomain.freeyourgadget.gadgetbridge.activities.charts.sleep;
import android.content.Context;
import android.graphics.Canvas;
@@ -25,9 +25,7 @@ import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.Region;
import android.graphics.Shader;
import android.os.Build;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
@@ -71,6 +69,8 @@ public class SleepDetailsView extends View {
private List<SleepDetail> sleepDetails = new ArrayList<>();
private int sleepMinutesCount = 0;
private SleepDetailsOverlay curOverlay = null;
private long startTimestamp;
private long endTimestamp;
@@ -91,12 +91,14 @@ public class SleepDetailsView extends View {
private final Paint timeInfoTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint infoTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint selectorPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path cornerPath = new Path();
private final Path rectPath = new Path();
private final Path detailBarPath = new Path();
private final RectF rectF = new RectF();
private final Rect timeInfoTextRect = new Rect();
private final Rect infoTextRect = new Rect();
private final int overlayAlpha = 128;
private final int infoTextSize = 25;
private final float cornerRadius = 10.0F;
@@ -180,6 +182,10 @@ public class SleepDetailsView extends View {
canvas.drawLine(chartLeftStart, chartTopStart, chartLeftStart, chartTopStart + chartHeight, gridPaint);
canvas.drawLine(chartLeftStart + chartWidth, chartTopStart, chartLeftStart + chartWidth, chartTopStart + chartHeight, gridPaint);
if(curOverlay != null) {
curOverlay.drawOverlayScale(canvas, lineSpacing, horizontalLineCount, chartTopStart, chartLeftStart + chartWidth);
}
}
private int getPositionFromType(int type) {
@@ -201,82 +207,6 @@ public class SleepDetailsView extends View {
return config[idx].color;
}
private enum Corner {
TOP_LEFT,
BOTTOM_LEFT,
TOP_RIGHT,
BOTTOM_RIGHT
}
private void drawCorner(Canvas canvas, float tipX, float tipY, float left, float barWidth, int color, Corner corner) {
final float halfBarHeight = barHeight / 2f;
final float curveEdgeHeight = halfBarHeight + 3f;
final float halfWidth = barWidth / 2f;
final boolean isLeft = (corner == Corner.TOP_LEFT || corner == Corner.BOTTOM_LEFT);
final boolean isTop = (corner == Corner.TOP_LEFT || corner == Corner.TOP_RIGHT);
final float horizontalOffset = isLeft ? -0.5f : 0.5f;
final float verticalOffset = isTop ? -curveEdgeHeight : curveEdgeHeight;
final float secondX = tipX + horizontalOffset;
final float thirdX = tipX + (horizontalOffset < 0 ? halfWidth : -halfWidth);
cornerPath.reset();
cornerPath.moveTo(tipX, tipY);
cornerPath.lineTo(secondX, tipY + verticalOffset);
cornerPath.lineTo(thirdX, tipY + verticalOffset);
cornerPath.lineTo(thirdX, tipY);
cornerPath.close();
final float top = isTop ? tipY - barHeight : tipY + halfBarHeight;
final float bottom = top + halfBarHeight;
rectPath.reset();
rectF.set(left, top, left + barWidth, bottom);
rectPath.addRoundRect(rectF, cornerRadius, cornerRadius, Path.Direction.CW);
cornerPaint.setColor(color);
canvas.save();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
canvas.clipOutPath(rectPath);
} else {
canvas.clipPath(rectPath, Region.Op.DIFFERENCE);
}
canvas.drawPath(cornerPath, fillPaint);
canvas.restore();
}
private void drawEdges(Canvas canvas, List<SleepDetail> details, int currentIndex, float left, float top) {
if (currentIndex < 0 || currentIndex >= details.size()) return;
SleepDetail currentData = details.get(currentIndex);
final int currentType = currentData.type;
final int currentIdx = getIndexByType(currentType);
if (currentIdx == -1) return;
final float posY = top + (barHeight / 2f);
final int color = getColorFromType(currentType);
final float currentUnitWidth = currentData.durationMinutes * unitWidth;
if (currentIndex > 0) {
final int prevIdx = getIndexByType(details.get(currentIndex - 1).type);
if (prevIdx != -1) {
Corner corner = (currentIdx > prevIdx) ? Corner.TOP_LEFT : Corner.BOTTOM_LEFT;
drawCorner(canvas, left, posY, left, currentUnitWidth, color, corner);
}
}
if (currentIndex < details.size() - 1) {
final int nextIdx = getIndexByType(details.get(currentIndex + 1).type);
if (nextIdx != -1) {
final float rightX = left + currentUnitWidth;
Corner corner = (currentIdx > nextIdx) ? Corner.TOP_RIGHT : Corner.BOTTOM_RIGHT;
drawCorner(canvas, rightX, posY, left, currentUnitWidth, color, corner);
}
}
}
private void drawConnections(Canvas canvas, List<SleepDetail> details, int currentIndex, float left) {
if (currentIndex <= 0 || currentIndex >= details.size()) {
return;
@@ -302,10 +232,96 @@ public class SleepDetailsView extends View {
Shader shader = new LinearGradient(left, topY, left, bottomY, startColor, endColor, Shader.TileMode.MIRROR);
connectionPaint.setShader(shader);
if (curOverlay != null) {
connectionPaint.setAlpha(overlayAlpha);
}
canvas.drawLine(left, topY, left, bottomY, connectionPaint);
connectionPaint.setShader(null);
}
public static final int CORNER_TOP_LEFT = 1;
public static final int CORNER_TOP_RIGHT = 2;
public static final int CORNER_BOTTOM_LEFT = 4;
public static final int CORNER_BOTTOM_RIGHT = 8;
public static boolean isFlagSet(int value, int flag) {
return (value & flag) == flag;
}
public static int setFlag(int value, int flag) {
return value | flag;
}
private void drawRoundedRectWithCorners(Canvas canvas, float left, float top, float width, float height, int corners) {
final float right = left + width;
final float bottom = top + height;
final float centerX = (left + right) / 2f;
detailBarPath.reset();
if (left + cornerRadius < centerX) {
detailBarPath.moveTo(left + cornerRadius, top);
detailBarPath.lineTo(right - cornerRadius, top);
} else {
detailBarPath.moveTo(centerX, top);
}
detailBarPath.quadTo(right, top, right, isFlagSet(corners, CORNER_TOP_RIGHT) ? top - cornerRadius : top + cornerRadius);
detailBarPath.lineTo(right, isFlagSet(corners, CORNER_BOTTOM_RIGHT) ? bottom + cornerRadius : bottom - cornerRadius);
if (left + cornerRadius < centerX) {
detailBarPath.quadTo(right, bottom, right - cornerRadius, bottom);
detailBarPath.lineTo(left + cornerRadius, bottom);
} else {
detailBarPath.quadTo(right, bottom, centerX, bottom);
}
detailBarPath.quadTo(left, bottom, left, isFlagSet(corners, CORNER_BOTTOM_LEFT) ? bottom + cornerRadius : bottom - cornerRadius);
detailBarPath.lineTo(left, isFlagSet(corners, CORNER_TOP_LEFT) ? top - cornerRadius : top + cornerRadius);
if (left + cornerRadius < centerX) {
detailBarPath.quadTo(left, top, left + cornerRadius, top);
} else {
detailBarPath.quadTo(left, top, centerX, top);
}
detailBarPath.close();
if (curOverlay != null) {
fillPaint.setAlpha(overlayAlpha);
}
canvas.drawPath(detailBarPath, fillPaint);
}
private int getEdgeFlags(List<SleepDetail> details, int currentIndex) {
int ret = 0;
if (currentIndex < 0 || currentIndex >= details.size()) return ret;
SleepDetail currentData = details.get(currentIndex);
final int currentType = currentData.type;
final int currentIdx = getIndexByType(currentType);
if (currentIdx == -1) return ret;
if (currentIndex > 0) {
final int prevIdx = getIndexByType(details.get(currentIndex - 1).type);
if (prevIdx != -1) {
ret = setFlag(ret, (currentIdx > prevIdx) ? CORNER_TOP_LEFT : CORNER_BOTTOM_LEFT);
}
}
if (currentIndex < details.size() - 1) {
final int nextIdx = getIndexByType(details.get(currentIndex + 1).type);
if (nextIdx != -1) {
ret = setFlag(ret, (currentIdx > nextIdx) ? CORNER_TOP_RIGHT : CORNER_BOTTOM_RIGHT);
}
}
return ret;
}
private void drawDetails(Canvas canvas) {
if (sleepDetails.isEmpty()) return;
@@ -318,10 +334,8 @@ public class SleepDetailsView extends View {
final float currentUnitWidth = sd.durationMinutes * unitWidth;
rectF.set(x, top, x + currentUnitWidth, top + barHeight);
canvas.drawRoundRect(rectF, cornerRadius, cornerRadius, fillPaint);
drawEdges(canvas, sleepDetails, i, x, top);
int corners = getEdgeFlags(sleepDetails, i);
drawRoundedRectWithCorners(canvas, x, top, currentUnitWidth, barHeight, corners);
drawConnections(canvas, sleepDetails, i, x);
x += currentUnitWidth;
@@ -371,12 +385,12 @@ public class SleepDetailsView extends View {
final int iconSize = infoTextSize;
final float infoIconX = chartLeftStart + ((float) chartWidth /2) - (float) infoTextRect.width() / 2 - iconSize - 5;
final float infoIconX = chartLeftStart + ((float) chartWidth / 2) - (float) infoTextRect.width() / 2 - iconSize - 5;
rectF.set(infoIconX, infoTextRect.height() - iconSize, infoIconX + iconSize, infoTextRect.height());
canvas.drawRoundRect(rectF, cornerRadius, cornerRadius, fillPaint);
final float infoTextX = chartLeftStart + ((float) chartWidth /2) - (float) infoTextRect.width() / 2;
final float infoTextX = chartLeftStart + ((float) chartWidth / 2) - (float) infoTextRect.width() / 2;
canvas.drawText(info, infoTextX, infoTextRect.height(), infoTextPaint);
}
@@ -405,6 +419,10 @@ public class SleepDetailsView extends View {
drawGrid(canvas);
drawDetails(canvas);
if (curOverlay != null) {
curOverlay.drawOverlay(canvas, chartLeftStart, chartTopStart, chartHeight, chartWidth);
}
//draw start/end Time Info
final String endDate = formatDurationHoursMinutes(this.endTimestamp);
float timeInfoY = chartTopStart + chartHeight + timeInfoHeight;
@@ -414,6 +432,9 @@ 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);
@@ -429,6 +450,7 @@ public class SleepDetailsView extends View {
}
public void setData(List<SleepDetail> sleepDetails) {
curOverlay = null;
this.sleepDetails = sleepDetails;
this.sleepMinutesCount = 0;
for (SleepDetail sd : sleepDetails) {
@@ -443,6 +465,11 @@ public class SleepDetailsView extends View {
invalidate();
}
public void setOverlay(SleepDetailsOverlay overlay) {
curOverlay = overlay;
}
@Override
public boolean performClick() {
@@ -466,12 +493,12 @@ public class SleepDetailsView extends View {
case MotionEvent.ACTION_MOVE: {
final float x = ev.getX();
final float dx = x - moveEventLastX;
if(dx != 0) {
if(selectorPos < 0) {
selectorPos = (dx < 0)?chartLeftStart + chartWidth:0;
if (dx != 0) {
if (selectorPos < 0) {
selectorPos = (dx < 0) ? chartLeftStart + chartWidth : 0;
}
selectorPos = Math.max(0, Math.min(chartLeftStart + chartWidth, selectorPos + dx));
if (isStart && Math.abs(dx) > 5.0) {
if (isStart && Math.abs(dx) > 10.0) {
getParent().requestDisallowInterceptTouchEvent(true);
isStart = false;
}
+20 -10
View File
@@ -1,4 +1,5 @@
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
@@ -171,24 +172,25 @@
</GridLayout>
</LinearLayout>
<nodomain.freeyourgadget.gadgetbridge.activities.charts.SleepDetailsView
<nodomain.freeyourgadget.gadgetbridge.activities.charts.sleep.SleepDetailsView
android:id="@+id/sleep_details"
android:layout_width="fill_parent"
android:layout_height="300dp"
android:layout_marginTop="15dp"
android:layout_marginBottom="25dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:layout_weight="4" />
<com.github.mikephil.charting.charts.LineChart
android:id="@+id/sleepchart"
android:layout_width="fill_parent"
android:layout_height="300dp"
android:layout_marginTop="15dp"
android:layout_marginEnd="2dp"
android:layout_marginBottom="25dp"
android:layout_weight="4" />
<com.google.android.material.chip.ChipGroup
android:id="@+id/sleep_chart_overlay_group"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_weight="4"
app:singleSelection="true"
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp"/>
<GridLayout
android:background="@color/gauge_line_color"
android:layout_width="match_parent"
@@ -277,5 +279,13 @@
</LinearLayout>
</GridLayout>
<com.github.mikephil.charting.charts.LineChart
android:id="@+id/sleepchart"
android:layout_width="fill_parent"
android:layout_height="300dp"
android:layout_marginTop="15dp"
android:layout_marginBottom="25dp"
android:layout_weight="4" />
</LinearLayout>
</ScrollView>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.chip.Chip
xmlns:android="http://schemas.android.com/apk/res/android"
style="@style/Widget.MaterialComponents.Chip.Choice"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAlignment="center"
android:textSize="15sp"
/>