Charts: additional sleep chart with sleep stages

This commit is contained in:
Me7c7
2025-08-14 18:57:51 +02:00
committed by José Rebelo
parent 3b5ea9bf7f
commit 3db92e7711
4 changed files with 627 additions and 79 deletions
@@ -48,7 +48,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
public abstract class AbstractActivityChartFragment<D extends ChartsData> extends AbstractChartFragment<D> {
public abstract class AbstractActivityChartFragment<D extends ChartsData> extends AbstractChartFragment<D> {
private static final Logger LOG = LoggerFactory.getLogger(AbstractActivityChartFragment.class);
public static final float Y_VALUE_DEEP_SLEEP = 0.01f;
@@ -112,7 +112,7 @@ public abstract class AbstractActivityChartFragment<D extends ChartsData> extend
CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(getContext());
if (prefs.getBoolean("chart_heartrate_color", false)) {
HEARTRATE_COLOR = ContextCompat.getColor(getContext(), R.color.chart_heartrate_alternative);
}else{
} else {
HEARTRATE_COLOR = ContextCompat.getColor(getContext(), R.color.chart_heartrate);
}
HEARTRATE_FILL_COLOR = ContextCompat.getColor(getContext(), R.color.chart_heartrate_fill);
@@ -147,19 +147,14 @@ public abstract class AbstractActivityChartFragment<D extends ChartsData> extend
}
protected Integer getColorFor(ActivityKind activityKind) {
switch (activityKind) {
case DEEP_SLEEP:
return akDeepSleep.color;
case LIGHT_SLEEP:
return akLightSleep.color;
case REM_SLEEP:
return akRemSleep.color;
case AWAKE_SLEEP:
return akAwakeSleep.color;
case ACTIVITY:
return akActivity.color;
}
return akActivity.color;
return switch (activityKind) {
case DEEP_SLEEP -> akDeepSleep.color;
case LIGHT_SLEEP -> akLightSleep.color;
case REM_SLEEP -> akRemSleep.color;
case AWAKE_SLEEP -> akAwakeSleep.color;
case ACTIVITY -> akActivity.color;
default -> akActivity.color;
};
}
protected SampleProvider<? extends AbstractActivitySample> getProvider(DBHandler db, GBDevice device) {
@@ -226,24 +221,13 @@ public abstract class AbstractActivityChartFragment<D extends ChartsData> extend
final float value;
if (type != ActivityKind.NOT_WORN) {
if (ActivityKind.isSleep(type) && sample.getIntensity() < 0) {
switch (type) {
case SLEEP_ANY:
case AWAKE_SLEEP:
value = 0.25f;
break;
case DEEP_SLEEP:
value = 0.10f;
break;
case LIGHT_SLEEP:
value = 0.15f;
break;
case REM_SLEEP:
value = 0.20f;
break;
default:
value = Y_VALUE_DEEP_SLEEP;
break;
}
value = switch (type) {
case SLEEP_ANY, AWAKE_SLEEP -> 0.25f;
case DEEP_SLEEP -> 0.10f;
case LIGHT_SLEEP -> 0.15f;
case REM_SLEEP -> 0.20f;
default -> Y_VALUE_DEEP_SLEEP;
};
} else {
value = sample.getIntensity();
}
@@ -303,26 +287,26 @@ public abstract class AbstractActivityChartFragment<D extends ChartsData> extend
List<ILineDataSet> lineDataSets = new ArrayList<>();
lineDataSets.add(createDataSet(
entries.get(getIndexOfActivity(ActivityKind.ACTIVITY)), akActivity.color, "Activity"
entries.get(getIndexOfActivity(ActivityKind.ACTIVITY)), akActivity.color, "Activity"
));
lineDataSets.add(createDataSet(
entries.get(getIndexOfActivity(ActivityKind.DEEP_SLEEP)), akDeepSleep.color, "Deep Sleep"
entries.get(getIndexOfActivity(ActivityKind.DEEP_SLEEP)), akDeepSleep.color, "Deep Sleep"
));
lineDataSets.add(createDataSet(
entries.get(getIndexOfActivity(ActivityKind.LIGHT_SLEEP)), akLightSleep.color, "Light Sleep"
entries.get(getIndexOfActivity(ActivityKind.LIGHT_SLEEP)), akLightSleep.color, "Light Sleep"
));
lineDataSets.add(createDataSet(
entries.get(getIndexOfActivity(ActivityKind.NOT_WORN)), akNotWorn.color, "Not worn"
entries.get(getIndexOfActivity(ActivityKind.NOT_WORN)), akNotWorn.color, "Not worn"
));
if (supportsRemSleep(gbDevice)) {
lineDataSets.add(createDataSet(
entries.get(getIndexOfActivity(ActivityKind.REM_SLEEP)), akRemSleep.color, "REM Sleep"
entries.get(getIndexOfActivity(ActivityKind.REM_SLEEP)), akRemSleep.color, "REM Sleep"
));
}
if (supportsAwakeSleep(gbDevice)) {
lineDataSets.add(createDataSet(
entries.get(getIndexOfActivity(ActivityKind.AWAKE_SLEEP)), akAwakeSleep.color, "Awake Sleep"
entries.get(getIndexOfActivity(ActivityKind.AWAKE_SLEEP)), akAwakeSleep.color, "Awake Sleep"
));
}
if (hr && !heartRateDataSets.isEmpty()) {
@@ -335,15 +319,39 @@ public abstract class AbstractActivityChartFragment<D extends ChartsData> extend
return new DefaultChartsData<>(lineData, xValueFormatter);
}
protected int getIndexOfActivity(ActivityKind kind) {
switch (kind) {
case DEEP_SLEEP: return 0;
case LIGHT_SLEEP: return 1;
case REM_SLEEP: return 2;
case AWAKE_SLEEP: return 3;
case NOT_WORN: return 4;
default: return 5; // treated as ActivityKind.ACTIVITY
public List<SleepDetailsView.SleepDetail> prepareStages(List<? extends ActivitySample> samples) {
List<SleepDetailsView.SleepDetail> result = new ArrayList<>();
if (samples.isEmpty()) {
return result;
}
int currentType = getIndexOfActivity(samples.get(0).getKind());
long timestamp = samples.get(0).getTimestamp() * 1000L;
int duration = 0;
for (ActivitySample sample : samples) {
int value = getIndexOfActivity(sample.getKind());
if (value != currentType) {
result.add(new SleepDetailsView.SleepDetail(currentType, duration, timestamp));
currentType = value;
timestamp = sample.getTimestamp() * 1000L;
duration = 0;
}
duration++;
}
result.add(new SleepDetailsView.SleepDetail(currentType, duration, timestamp));
return result;
}
protected int getIndexOfActivity(ActivityKind kind) {
return switch (kind) {
case DEEP_SLEEP -> 0;
case LIGHT_SLEEP -> 1;
case REM_SLEEP -> 2;
case AWAKE_SLEEP -> 3;
case NOT_WORN -> 4;
default -> 5; // treated as ActivityKind.ACTIVITY
};
}
protected Entry createLineEntry(float value, int xValue) {
@@ -421,7 +429,7 @@ public abstract class AbstractActivityChartFragment<D extends ChartsData> extend
tsStart = toTimestamp(day.getTime());
int tsEnd = getTSEnd();
day.setTimeInMillis(tsEnd* 1000L);
day.setTimeInMillis(tsEnd * 1000L);
day.set(Calendar.HOUR_OF_DAY, SLEEP_HOUR_LIMIT);
day.set(Calendar.MINUTE, 0);
day.set(Calendar.SECOND, 0);
@@ -29,6 +29,7 @@ import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import androidx.core.content.ContextCompat;
@@ -88,6 +89,7 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
private int heartRateAvg = 0;
private float intensityTotal = 0;
private SleepDetailsView mSleepDetailsView;
private int mSmartAlarmFrom = -1;
@@ -95,9 +97,9 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
private int mTimestampFrom = -1;
private int mSmartAlarmGoneOff = -1;
Prefs prefs = GBApplication.getPrefs();
private boolean CHARTS_SLEEP_RANGE_24H = prefs.getBoolean("chart_sleep_range_24h", false);
private boolean SHOW_CHARTS_AVERAGE = prefs.getBoolean("charts_show_average", true);
private int sleepLinesLimit = prefs.getInt("chart_sleep_lines_limit", 6);
private final boolean CHARTS_SLEEP_RANGE_24H = prefs.getBoolean("chart_sleep_range_24h", false);
private final boolean SHOW_CHARTS_AVERAGE = prefs.getBoolean("charts_show_average", true);
private final int sleepLinesLimit = prefs.getInt("chart_sleep_lines_limit", 6);
@Override
protected boolean isSingleDay() {
@@ -119,7 +121,7 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
MySleepChartsData mySleepChartsData = refreshSleepAmounts(device, samples, sleepScoreSamples);
if (!CHARTS_SLEEP_RANGE_24H) {
if (mySleepChartsData.sleepSessions.size() > 0) {
if (!mySleepChartsData.sleepSessions.isEmpty()) {
long tstart = mySleepChartsData.sleepSessions.get(0).getSleepStart().getTime() / 1000;
long tend = mySleepChartsData.sleepSessions.get(mySleepChartsData.sleepSessions.size() - 1).getSleepEnd().getTime() / 1000;
@@ -134,7 +136,9 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
DefaultChartsData<LineData> chartsData = refresh(device, samples);
Triple<Float, Integer, Integer> hrData = calculateHrData(samples);
Triple<Float, Float, Float> intensityData = calculateIntensityData(samples);
return new MyChartsData(mySleepChartsData, chartsData, hrData.getLeft(), hrData.getMiddle(), hrData.getRight(), intensityData.getLeft(), intensityData.getMiddle(), intensityData.getRight());
List<SleepDetailsView.SleepDetail> stages = prepareStages(samples);
return new MyChartsData(mySleepChartsData, chartsData, hrData.getLeft(), hrData.getMiddle(), hrData.getRight(), intensityData.getLeft(), intensityData.getMiddle(), intensityData.getRight(), stages);
}
private MySleepChartsData refreshSleepAmounts(GBDevice mGBDevice, List<? extends ActivitySample> samples, List<? extends SleepScoreSample> sleepScoreSamples) {
@@ -188,14 +192,14 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
}
protected void sleepStagesGaugeUpdate(MySleepChartsData pieData) {
int[] colors = new int[] {
int[] colors = new int[]{
ContextCompat.getColor(GBApplication.getContext(), R.color.chart_light_sleep_light),
ContextCompat.getColor(GBApplication.getContext(), R.color.chart_deep_sleep_light),
ContextCompat.getColor(GBApplication.getContext(), R.color.chart_rem_sleep_light),
ContextCompat.getColor(GBApplication.getContext(), R.color.chart_awake_sleep_light),
};
long total = pieData.getTotalSleep() + pieData.getTotalAwake();
float[] segments = new float[] {
float[] segments = new float[]{
pieData.getTotalLight() > 0 ? (float) pieData.getTotalLight() / total : 0,
pieData.getTotalDeep() > 0 ? (float) pieData.getTotalDeep() / total : 0,
pieData.getTotalRem() > 0 ? (float) pieData.getTotalRem() / total : 0,
@@ -230,6 +234,10 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
protected void updateChartsnUIThread(MyChartsData mcd) {
MySleepChartsData pieData = mcd.getPieData();
if (mcd.getStages() != null) {
mSleepDetailsView.setData(mcd.getStages());
}
Date date = new Date((long) this.getTSEnd() * 1000);
String formattedDate = new SimpleDateFormat("E, MMM dd").format(date);
sleepDateText.setText(formattedDate);
@@ -284,7 +292,7 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
}
private Triple<Float, Integer, Integer> calculateHrData(List<? extends ActivitySample> samples) {
if (samples.size() < 1) {
if (samples.isEmpty()) {
return Triple.of(0f, 0, 0);
}
@@ -298,7 +306,7 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
}
}
}
if (heartRateValues.size() < 1) {
if (heartRateValues.isEmpty()) {
return Triple.of(0f, 0, 0);
}
@@ -327,7 +335,7 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
}
private Triple<Float, Float, Float> calculateIntensityData(List<? extends ActivitySample> samples) {
if (samples.size() < 1) {
if (samples.isEmpty()) {
return Triple.of(0f, 0f, 0f);
}
@@ -339,7 +347,7 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
allIntensities.add(intensity);
}
}
if (allIntensities.size() < 1) {
if (allIntensities.isEmpty()) {
return Triple.of(0f, 0f, 0f);
}
@@ -366,7 +374,6 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
@@ -374,7 +381,7 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
rootView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> {
getChartsHost().enableSwipeRefresh(scrollY == 0);
getChartsHost().enableSwipeRefresh(scrollY == 0);
});
}
@@ -393,10 +400,21 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
movementIntensityText = rootView.findViewById(R.id.sleep_movement_intensity);
sleepDateText = rootView.findViewById(R.id.sleep_date);
mSleepchartInfo.setMaxLines(sleepLinesLimit);
setupActivityChart();
mSleepDetailsView = rootView.findViewById(R.id.sleep_details);
SleepDetailsView.DataConfig[] config = new SleepDetailsView.DataConfig[]{
new SleepDetailsView.DataConfig(getIndexOfActivity(ActivityKind.AWAKE_SLEEP), ContextCompat.getColor(GBApplication.getContext(), R.color.chart_awake_sleep_light)),
new SleepDetailsView.DataConfig(getIndexOfActivity(ActivityKind.REM_SLEEP), ContextCompat.getColor(GBApplication.getContext(), R.color.chart_rem_sleep_light)),
new SleepDetailsView.DataConfig(getIndexOfActivity(ActivityKind.LIGHT_SLEEP), ContextCompat.getColor(GBApplication.getContext(), R.color.chart_light_sleep_light)),
new SleepDetailsView.DataConfig(getIndexOfActivity(ActivityKind.DEEP_SLEEP), ContextCompat.getColor(GBApplication.getContext(), R.color.chart_deep_sleep_light)),
};
mSleepDetailsView.setConfig(config);
// refresh immediately instead of use refreshIfVisible(), for perceived performance
refresh();
@@ -406,7 +424,7 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if (action.equals(ChartsHost.REFRESH)) {
if (action != null && action.equals(ChartsHost.REFRESH)) {
// TODO: use LimitLines to visualize smart alarms?
mSmartAlarmFrom = intent.getIntExtra("smartalarm_from", -1);
mSmartAlarmTo = intent.getIntExtra("smartalarm_to", -1);
@@ -473,13 +491,13 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
mActivityChart.animateX(ANIM_TIME, Easing.EaseInOutQuart);
}
private static class MySleepChartsData extends ChartsData {
private long totalSleep;
private long totalAwake;
private long totalRem;
private long totalDeep;
private long totalLight;
private int sleepScore;
protected static class MySleepChartsData extends ChartsData {
private final long totalSleep;
private final long totalAwake;
private final long totalRem;
private final long totalDeep;
private final long totalLight;
private final int sleepScore;
private final List<SleepSession> sleepSessions;
public MySleepChartsData(List<SleepSession> sleepSessions, long totalSleep, long totalAwake, long totalRem, long totalDeep, long totalLight, int sleepScore) {
@@ -512,7 +530,9 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
return totalLight;
}
public int getSleepScore() {return sleepScore;}
public int getSleepScore() {
return sleepScore;
}
public List<SleepSession> getSleepSessions() {
return sleepSessions;
@@ -523,13 +543,15 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
private final DefaultChartsData<LineData> chartsData;
private final MySleepChartsData pieData;
private final float heartRateAverage;
private int heartRateAxisMax;
private int heartRateAxisMin;
private float intensityAxisMax;
private float intensityAxisMin;
private float intensityTotal;
private final int heartRateAxisMax;
private final int heartRateAxisMin;
private final float intensityAxisMax;
private final float intensityAxisMin;
private final float intensityTotal;
public MyChartsData(MySleepChartsData pieData, DefaultChartsData<LineData> chartsData, float heartRateAverage, int heartRateAxisMin, int heartRateAxisMax, float intensityTotal, float intensityAxisMin, float intensityAxisMax) {
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) {
this.pieData = pieData;
this.chartsData = chartsData;
this.heartRateAverage = heartRateAverage;
@@ -538,6 +560,7 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
this.intensityTotal = intensityTotal;
this.intensityAxisMin = intensityAxisMin;
this.intensityAxisMax = intensityAxisMax;
this.stages = stages;
}
public MySleepChartsData getPieData() {
@@ -556,7 +579,9 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
return heartRateAxisMax;
}
public int getHeartRateAxisMin() { return heartRateAxisMin; }
public int getHeartRateAxisMin() {
return heartRateAxisMin;
}
public float getIntensityAxisMax() {
return intensityAxisMax;
@@ -566,7 +591,12 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
return intensityAxisMin;
}
public float getIntensityTotal() { return intensityTotal;}
public float getIntensityTotal() {
return intensityTotal;
}
public List<SleepDetailsView.SleepDetail> getStages() {
return stages;
}
}
}
@@ -0,0 +1,501 @@
/* 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;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.DashPathEffect;
import android.graphics.LinearGradient;
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;
import androidx.annotation.NonNull;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
public class SleepDetailsView extends View {
public static class SleepDetail {
public int type;
public int durationMinutes;
public long startTimestamp;
public SleepDetail(int type, int durationMinutes, long startTimestamp) {
this.type = type;
this.durationMinutes = durationMinutes;
this.startTimestamp = startTimestamp;
}
}
public static class DataConfig {
public int type;
public int color;
public DataConfig(int type, int color) {
this.type = type;
this.color = color;
}
}
private List<SleepDetail> sleepDetails = new ArrayList<>();
private int sleepMinutesCount = 0;
private long startTimestamp;
private long endTimestamp;
private DataConfig[] config;
private float heightUnit;
private float unitWidth;
private int horizontalLineCount;
private float barHeight;
private final Paint gridPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint connectionPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint fillPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint cornerPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
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 RectF rectF = new RectF();
private final Rect timeInfoTextRect = new Rect();
private final Rect infoTextRect = new Rect();
private final int infoTextSize = 25;
private final float cornerRadius = 10.0F;
private float selectorPos = -1.0F;
private float moveEventLastX = 0.0F;
private int chartLeftStart;
private int chartWidth;
private int chartTopStart;
private int chartHeight;
public SleepDetailsView(Context context) {
super(context);
init();
}
public SleepDetailsView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public SleepDetailsView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
public void init() {
config = null;
horizontalLineCount = 8;
heightUnit = (float) (1.0 / 8);
gridPaint.setColor(Color.GRAY);
gridPaint.setStrokeWidth(1.0F);
gridPaint.setStrokeCap(Paint.Cap.ROUND);
gridPaint.setStyle(Paint.Style.STROKE);
gridPaint.setPathEffect(new DashPathEffect(new float[]{2f, 10f}, 0f));
fillPaint.setStyle(Paint.Style.FILL);
cornerPaint.setStyle(Paint.Style.FILL);
connectionPaint.setStyle(Paint.Style.FILL);
timeInfoTextPaint.setColor(Color.GRAY);
timeInfoTextPaint.setTextAlign(Paint.Align.CENTER);
timeInfoTextPaint.setTextSize(25);
infoTextPaint.setColor(Color.GRAY);
infoTextPaint.setTextAlign(Paint.Align.LEFT);
infoTextPaint.setTextSize(infoTextSize);
selectorPaint.setColor(Color.GRAY);
selectorPaint.setStrokeWidth(1.0F);
selectorPaint.setStrokeCap(Paint.Cap.ROUND);
selectorPaint.setStyle(Paint.Style.STROKE);
selectorPaint.setPathEffect(new DashPathEffect(new float[]{2f, 10f}, 0f));
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
}
private int getIndexByType(int type) {
if (config == null)
return -1;
for (int i = 0; i < config.length; i++) {
if (config[i].type == type) {
return i;
}
}
return -1;
}
private void drawGrid(Canvas canvas) {
final int lineSpacing = (chartHeight) / horizontalLineCount;
for (int i = 0; i <= horizontalLineCount; i++) {
final float y = (i * lineSpacing);
canvas.drawLine(chartLeftStart, chartTopStart + y, chartLeftStart + chartWidth, chartTopStart + y, gridPaint);
}
canvas.drawLine(chartLeftStart, chartTopStart, chartLeftStart, chartTopStart + chartHeight, gridPaint);
canvas.drawLine(chartLeftStart + chartWidth, chartTopStart, chartLeftStart + chartWidth, chartTopStart + chartHeight, gridPaint);
}
private int getPositionFromType(int type) {
if (config == null) {
return 7;
}
int idx = getIndexByType(type);
if (idx == -1) {
return config.length * 2 - 1;
}
return idx * 2;
}
private int getColorFromType(int type) {
int idx = getIndexByType(type);
if (idx == -1) {
return Color.GRAY;
}
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;
}
int prevType = details.get(currentIndex - 1).type;
int curType = details.get(currentIndex).type;
int prevIdx = getIndexByType(prevType);
int curIdx = getIndexByType(curType);
if (prevIdx == -1 || curIdx == -1) {
return;
}
float prevY = chartTopStart + getPositionFromType(prevType) * barHeight;
float curY = chartTopStart + getPositionFromType(curType) * barHeight;
float topY = Math.min(prevY, curY) + barHeight;
float bottomY = Math.max(prevY, curY);
int startColor = (curIdx < prevIdx) ? getColorFromType(curType) : getColorFromType(prevType);
int endColor = (curIdx < prevIdx) ? getColorFromType(prevType) : getColorFromType(curType);
Shader shader = new LinearGradient(left, topY, left, bottomY, startColor, endColor, Shader.TileMode.MIRROR);
connectionPaint.setShader(shader);
canvas.drawLine(left, topY, left, bottomY, connectionPaint);
connectionPaint.setShader(null);
}
private void drawDetails(Canvas canvas) {
if (sleepDetails.isEmpty()) return;
float x = chartLeftStart;
for (int i = 0, n = sleepDetails.size(); i < n; i++) {
SleepDetail sd = sleepDetails.get(i);
float top = chartTopStart + getPositionFromType(sd.type) * barHeight;
fillPaint.setColor(getColorFromType(sd.type));
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);
drawConnections(canvas, sleepDetails, i, x);
x += currentUnitWidth;
}
}
public static String formatDurationHoursMinutes(long duration) {
Date date = new Date(duration);
SimpleDateFormat sdf = new SimpleDateFormat("HH:mm", Locale.getDefault());
return sdf.format(date);
}
private String timeStringFormat(long seconds) {
return DateTimeUtils.formatDurationHoursMinutes(seconds, TimeUnit.SECONDS);
}
private void drawSelectedInfo(Canvas canvas) {
float x = chartLeftStart;
SleepDetail curDetail = null;
for (int i = 0; i < sleepDetails.size(); i++) {
final SleepDetail detail = sleepDetails.get(i);
final float curXEnd = x + detail.durationMinutes * unitWidth;
boolean selected = (i < sleepDetails.size() - 1)
? (selectorPos >= x && selectorPos < curXEnd)
: (selectorPos >= x && selectorPos <= curXEnd);
if (selected) {
curDetail = detail;
break;
}
x = curXEnd;
}
if (curDetail == null) {
return;
}
final long endTimestamp = curDetail.startTimestamp + ((curDetail.durationMinutes) * 60000L);
final String startStr = SleepDetailsView.formatDurationHoursMinutes(curDetail.startTimestamp);
final String endStr = SleepDetailsView.formatDurationHoursMinutes(endTimestamp);
final String info = String.format("%s - %s (%s)", startStr, endStr, timeStringFormat(curDetail.durationMinutes * 60L));
infoTextPaint.getTextBounds(info, 0, info.length(), infoTextRect);
fillPaint.setColor(getColorFromType(curDetail.type));
final int iconSize = infoTextSize;
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;
canvas.drawText(info, infoTextX, infoTextRect.height(), infoTextPaint);
}
@Override
protected void onDraw(@NonNull Canvas canvas) {
super.onDraw(canvas);
int height = getHeight();
int width = getWidth();
final String startDate = formatDurationHoursMinutes(this.startTimestamp);
timeInfoTextPaint.getTextBounds(startDate, 0, startDate.length(), timeInfoTextRect);
final int timeInfoHeight = timeInfoTextRect.height() * 2;
chartLeftStart = timeInfoTextRect.width() / 2;
chartWidth = width - chartLeftStart - (timeInfoTextRect.width() / 2);
chartTopStart = infoTextSize * 2;
chartHeight = height - chartTopStart - timeInfoHeight;
barHeight = (chartHeight) * heightUnit;
unitWidth = sleepMinutesCount > 0 ? ((float) chartWidth / sleepMinutesCount) : 0;
drawGrid(canvas);
drawDetails(canvas);
//draw start/end Time Info
final String endDate = formatDurationHoursMinutes(this.endTimestamp);
float timeInfoY = chartTopStart + chartHeight + timeInfoHeight;
canvas.drawText(startDate, chartLeftStart, timeInfoY, timeInfoTextPaint);
canvas.drawText(endDate, width - (float) timeInfoTextRect.width() / 2, timeInfoY, timeInfoTextPaint);
//draw selector
if (selectorPos > chartLeftStart && selectorPos < chartLeftStart + chartWidth) {
canvas.drawLine(selectorPos, chartTopStart, selectorPos, chartTopStart + chartHeight, selectorPaint);
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);
}
}
public void setConfig(DataConfig[] config) {
this.config = config;
horizontalLineCount = config.length * 2;
heightUnit = (float) (1.0 / (config.length * 2));
}
public void setData(List<SleepDetail> sleepDetails) {
this.sleepDetails = sleepDetails;
this.sleepMinutesCount = 0;
for (SleepDetail sd : sleepDetails) {
this.sleepMinutesCount += sd.durationMinutes;
}
if (!sleepDetails.isEmpty()) {
this.startTimestamp = sleepDetails.get(0).startTimestamp;
SleepDetail last = sleepDetails.get(sleepDetails.size() - 1);
this.endTimestamp = last.startTimestamp + ((last.durationMinutes) * 60000L);
}
selectorPos = -1.0F;
invalidate();
}
@Override
public boolean performClick() {
super.performClick();
// TODO: added only to suppress warning. I don't know what to do with this method here.
return true;
}
private boolean isStart = false;
@Override
public boolean onTouchEvent(MotionEvent ev) {
final int action = ev.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN: {
moveEventLastX = ev.getX();
isStart = true;
break;
}
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;
}
selectorPos = Math.max(0, Math.min(chartLeftStart + chartWidth, selectorPos + dx));
if (isStart && Math.abs(dx) > 5.0) {
getParent().requestDisallowInterceptTouchEvent(true);
isStart = false;
}
moveEventLastX = x;
invalidate();
}
break;
}
case MotionEvent.ACTION_UP: {
moveEventLastX = ev.getX();
performClick();
getParent().requestDisallowInterceptTouchEvent(false);
isStart = false;
break;
}
case MotionEvent.ACTION_CANCEL: {
getParent().requestDisallowInterceptTouchEvent(false);
isStart = false;
break;
}
default:
break;
}
return true;
}
}
@@ -165,6 +165,16 @@
</GridLayout>
</LinearLayout>
<nodomain.freeyourgadget.gadgetbridge.activities.charts.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"
@@ -257,6 +267,5 @@
</LinearLayout>
</GridLayout>
</LinearLayout>
</ScrollView>