mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
Charts: Simple heart rate overlay to sleep chart added
This commit is contained in:
+36
@@ -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;
|
||||
|
||||
+39
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+209
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
+118
-91
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
Reference in New Issue
Block a user