mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
Charts: additional sleep chart with sleep stages
This commit is contained in:
+56
-48
@@ -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);
|
||||
|
||||
+60
-30
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+501
@@ -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>
|
||||
Reference in New Issue
Block a user