From 82657febf8d04196d7d8b8a04cd225c0d1a8cb29 Mon Sep 17 00:00:00 2001 From: a0z Date: Fri, 9 Aug 2024 21:35:47 +0000 Subject: [PATCH] Garmin body energy level (#3964) Co-authored-by: a0z Co-committed-by: a0z --- .../gadgetbridge/GBApplication.java | 32 +- .../charts/ActivityChartsActivity.java | 7 + .../activities/charts/BodyEnergyFragment.java | 291 ++++++++++++++++++ .../main/res/layout/fragment_body_energy.xml | 126 ++++++++ app/src/main/res/values/arrays.xml | 3 + app/src/main/res/values/colors.xml | 2 + app/src/main/res/values/strings.xml | 4 + app/src/main/res/values/values.xml | 1 + 8 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BodyEnergyFragment.java create mode 100644 app/src/main/res/layout/fragment_body_energy.xml diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java index ecf69fd08..cfe73e0be 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java @@ -122,7 +122,7 @@ public class GBApplication extends Application { private static SharedPreferences sharedPrefs; private static final String PREFS_VERSION = "shared_preferences_version"; //if preferences have to be migrated, increment the following and add the migration logic in migratePrefs below; see http://stackoverflow.com/questions/16397848/how-can-i-migrate-android-preferences-with-a-new-version - private static final int CURRENT_PREFS_VERSION = 31; + private static final int CURRENT_PREFS_VERSION = 32; private static final LimitedQueue mIDSenderLookup = new LimitedQueue<>(16); private static GBPrefs prefs; @@ -1513,6 +1513,36 @@ public class GBApplication extends Application { } } + if (oldVersion < 32) { + // Add the new HRV Status tab to all devices + try (DBHandler db = acquireDB()) { + final DaoSession daoSession = db.getDaoSession(); + final List activeDevices = DBHelper.getActiveDevices(daoSession); + + for (final Device dbDevice : activeDevices) { + final SharedPreferences deviceSharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(dbDevice.getIdentifier()); + + final String chartsTabsValue = deviceSharedPrefs.getString("charts_tabs", null); + if (chartsTabsValue == null) { + continue; + } + + final String newPrefValue; + if (!StringUtils.isBlank(chartsTabsValue)) { + newPrefValue = chartsTabsValue + ",bodyenergy"; + } else { + newPrefValue = "bodyenergy"; + } + + final SharedPreferences.Editor deviceSharedPrefsEdit = deviceSharedPrefs.edit(); + deviceSharedPrefsEdit.putString("charts_tabs", newPrefValue); + deviceSharedPrefsEdit.apply(); + } + } catch (Exception e) { + Log.w(TAG, "error acquiring DB lock"); + } + } + editor.putString(PREFS_VERSION, Integer.toString(CURRENT_PREFS_VERSION)); editor.apply(); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityChartsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityChartsActivity.java index c8255ea7c..b1b4925ef 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityChartsActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/ActivityChartsActivity.java @@ -114,6 +114,9 @@ public class ActivityChartsActivity extends AbstractChartsActivity { if (!coordinator.supportsHrvMeasurement()) { tabList.remove("hrvstatus"); } + if (!coordinator.supportsBodyEnergy()) { + tabList.remove("bodyenergy"); + } return tabList; } @@ -145,6 +148,8 @@ public class ActivityChartsActivity extends AbstractChartsActivity { return new WeekSleepChartFragment(); case "hrvstatus": return new HRVStatusFragment(); + case "bodyenergy": + return new BodyEnergyFragment(); case "stress": return new StressChartFragment(); case "pai": @@ -199,6 +204,8 @@ public class ActivityChartsActivity extends AbstractChartsActivity { return getSleepTitle(); case "hrvstatus": return getString(R.string.pref_header_hrv_status); + case "bodyenergy": + return getString(R.string.body_energy); case "stress": return getString(R.string.menuitem_stress); case "pai": diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BodyEnergyFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BodyEnergyFragment.java new file mode 100644 index 000000000..d3e6e6237 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BodyEnergyFragment.java @@ -0,0 +1,291 @@ +package nodomain.freeyourgadget.gadgetbridge.activities.charts; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.os.Bundle; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.ColorInt; + +import com.github.mikephil.charting.charts.Chart; +import com.github.mikephil.charting.charts.LineChart; +import com.github.mikephil.charting.components.LegendEntry; +import com.github.mikephil.charting.components.XAxis; +import com.github.mikephil.charting.components.YAxis; +import com.github.mikephil.charting.data.Entry; +import com.github.mikephil.charting.data.LineData; +import com.github.mikephil.charting.data.LineDataSet; +import com.github.mikephil.charting.formatter.ValueFormatter; +import com.github.mikephil.charting.interfaces.datasets.ILineDataSet; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; +import java.util.concurrent.atomic.AtomicInteger; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; +import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.BodyEnergySample; + + +public class BodyEnergyFragment extends AbstractChartFragment { + protected static final Logger LOG = LoggerFactory.getLogger(BodyEnergyFragment.class); + + private TextView mDateView; + private ImageView bodyEnergyGauge; + private TextView bodyEnergyGained; + private TextView bodyEnergyLost; + private LineChart bodyEnergyChart; + + protected int CHART_TEXT_COLOR; + protected int LEGEND_TEXT_COLOR; + protected int TEXT_COLOR; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View rootView = inflater.inflate(R.layout.fragment_body_energy, container, false); + + mDateView = rootView.findViewById(R.id.body_energy_date_view); + bodyEnergyGauge = rootView.findViewById(R.id.body_energy_gauge); + bodyEnergyGained = rootView.findViewById(R.id.body_energy_gained); + bodyEnergyLost = rootView.findViewById(R.id.body_energy_lost); + bodyEnergyChart = rootView.findViewById(R.id.body_energy_chart); + setupBodyEnergyLevelChart(); + refresh(); + + + return rootView; + } + + + @Override + public String getTitle() { + return getString(R.string.body_energy); + } + + @Override + protected void init() { + TEXT_COLOR = GBApplication.getTextColor(requireContext()); + LEGEND_TEXT_COLOR = GBApplication.getTextColor(requireContext()); + CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext()); + } + + @Override + protected BodyEnergyData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) { + String formattedDate = new SimpleDateFormat("E, MMM dd").format(getEndDate()); + mDateView.setText(formattedDate); + List samples = getBodyEnergySamples(db, device, getTSStart(), getTSEnd()); + return new BodyEnergyData(samples); + } + + @Override + protected void updateChartsnUIThread(BodyEnergyData bodyEnergyData) { + + List lineEntries = new ArrayList<>(); + final List lineDataSets = new ArrayList<>(); + final AtomicInteger gainedValue = new AtomicInteger(0); + final AtomicInteger drainedValue = new AtomicInteger(0); + int newestValue = 0; + long referencedTimestamp; + if (!bodyEnergyData.samples.isEmpty()) { + newestValue = bodyEnergyData.samples.get(bodyEnergyData.samples.size() - 1).getEnergy(); + referencedTimestamp = bodyEnergyData.samples.get(0).getTimestamp(); + final AtomicInteger[] lastValue = {new AtomicInteger(0)}; + bodyEnergyData.samples.forEach((sample) -> { + if (sample.getEnergy() < lastValue[0].intValue()) { + drainedValue.set(drainedValue.get() + lastValue[0].intValue() - sample.getEnergy()); + } else if (lastValue[0].intValue() > 0 && sample.getEnergy() > lastValue[0].intValue()) { + gainedValue.set(gainedValue.get() + sample.getEnergy() - lastValue[0].intValue()); + } + lastValue[0].set(sample.getEnergy()); + float x = (float) sample.getTimestamp() / 1000 - (float) referencedTimestamp / 1000; + lineEntries.add(new Entry(x, sample.getEnergy())); + }); + } + + final LineDataSet lineDataSet = new LineDataSet(lineEntries, getString(R.string.body_energy_legend_level)); + lineDataSet.setColor(getResources().getColor(R.color.body_energy_level_color)); + lineDataSet.setDrawCircles(false); + lineDataSet.setLineWidth(2f); + lineDataSet.setFillAlpha(255); + lineDataSet.setDrawCircles(false); + lineDataSet.setCircleColor(getResources().getColor(R.color.body_energy_level_color)); + lineDataSet.setAxisDependency(YAxis.AxisDependency.LEFT); + lineDataSet.setDrawValues(false); + lineDataSet.setMode(LineDataSet.Mode.CUBIC_BEZIER); + lineDataSet.setDrawFilled(true); + lineDataSet.setFillAlpha(60); + lineDataSet.setFillColor(getResources().getColor(R.color.body_energy_level_color )); + + List legendEntries = new ArrayList<>(1); + LegendEntry activityEntry = new LegendEntry(); + activityEntry.label = getString(R.string.body_energy_legend_level); + activityEntry.formColor = getResources().getColor(R.color.body_energy_level_color); + legendEntries.add(activityEntry); + bodyEnergyChart.getLegend().setTextColor(LEGEND_TEXT_COLOR); + bodyEnergyChart.getLegend().setCustom(legendEntries); + + lineDataSets.add(lineDataSet); + final LineData lineData = new LineData(lineDataSets); + bodyEnergyChart.setData(lineData); + bodyEnergyGauge.setImageBitmap(drawGauge( + 300, + 20, + getResources().getColor(R.color.body_energy_level_color), + newestValue, + 100 + )); + bodyEnergyGained.setText(String.format("+ %s", gainedValue.intValue())); + bodyEnergyLost.setText(String.format("- %s", drainedValue)); + } + + @Override + protected void renderCharts() { + bodyEnergyChart.invalidate(); + } + + + public List getBodyEnergySamples(final DBHandler db, final GBDevice device, int tsFrom, int tsTo) { + Calendar day = Calendar.getInstance(); + day.setTimeInMillis(tsTo * 1000L); //we need today initially, which is the end of the time range + day.set(Calendar.HOUR_OF_DAY, 0); //and we set time for the start and end of the same day + day.set(Calendar.MINUTE, 0); + day.set(Calendar.SECOND, 0); + tsFrom = (int) (day.getTimeInMillis() / 1000); + tsTo = tsFrom + 24 * 60 * 60 - 1; + + final DeviceCoordinator coordinator = device.getDeviceCoordinator(); + final TimeSampleProvider sampleProvider = coordinator.getBodyEnergySampleProvider(device, db.getDaoSession()); + return sampleProvider.getAllSamples(tsFrom * 1000L, tsTo * 1000L); + } + + protected void setupLegend(Chart chart) {} + + Bitmap drawGauge(int width, int barWidth, @ColorInt int filledColor, int value, int maxValue) { + int height = width; + int barMargin = (int) Math.ceil(barWidth / 2f); + float filledFactor = (float) value / maxValue; + + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + Paint paint = new Paint(); + paint.setAntiAlias(true); + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeCap(Paint.Cap.ROUND); + paint.setStrokeWidth(barWidth); + paint.setColor(getResources().getColor(R.color.gauge_line_color)); + canvas.drawArc( + barMargin, + barMargin, + width - barMargin, + width - barMargin, + 120, + 300, + false, + paint); + paint.setStrokeWidth(barWidth); + paint.setColor(filledColor); + canvas.drawArc( + barMargin, + barMargin, + width - barMargin, + height - barMargin, + 120, + 300 * filledFactor, + false, + paint + ); + + Paint textPaint = new Paint(); + textPaint.setColor(TEXT_COLOR); + float textPixels = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 18, requireContext().getResources().getDisplayMetrics()); + textPaint.setTextSize(textPixels); + textPaint.setTextAlign(Paint.Align.CENTER); + int yPos = (int) ((float) height / 2 - ((textPaint.descent() + textPaint.ascent()) / 2)) ; + canvas.drawText(String.valueOf(value), width / 2f, yPos, textPaint); + Paint textLowerPaint = new Paint(); + textLowerPaint.setColor(TEXT_COLOR); + textLowerPaint.setTextAlign(Paint.Align.CENTER); + float textLowerPixels = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 8, requireContext().getResources().getDisplayMetrics()); + textLowerPaint.setTextSize(textLowerPixels); + int yPosLowerText = (int) ((float) height / 2 - textPaint.ascent()) ; + canvas.drawText(String.valueOf(maxValue), width / 2f, yPosLowerText, textLowerPaint); + + return bitmap; + } + + private void setupBodyEnergyLevelChart() { + bodyEnergyChart.getDescription().setEnabled(false); + bodyEnergyChart.setTouchEnabled(false); + bodyEnergyChart.setPinchZoom(false); + bodyEnergyChart.setDoubleTapToZoomEnabled(false); + + + final XAxis xAxisBottom = bodyEnergyChart.getXAxis(); + xAxisBottom.setPosition(XAxis.XAxisPosition.BOTTOM); + xAxisBottom.setDrawLabels(true); + xAxisBottom.setDrawGridLines(false); + xAxisBottom.setEnabled(true); + xAxisBottom.setDrawLimitLinesBehindData(true); + xAxisBottom.setTextColor(CHART_TEXT_COLOR); + xAxisBottom.setAxisMinimum(0f); + xAxisBottom.setAxisMaximum(86400f); + xAxisBottom.setLabelCount(7, true); + xAxisBottom.setValueFormatter(getBodyEnergyChartXValueFormatter()); + + final YAxis yAxisLeft = bodyEnergyChart.getAxisLeft(); + yAxisLeft.setDrawGridLines(true); + yAxisLeft.setAxisMaximum(100); + yAxisLeft.setAxisMinimum(0); + yAxisLeft.setDrawTopYLabelEntry(true); + yAxisLeft.setEnabled(true); + yAxisLeft.setTextColor(CHART_TEXT_COLOR); + + final YAxis yAxisRight = bodyEnergyChart.getAxisRight(); + yAxisRight.setEnabled(true); + yAxisRight.setDrawLabels(false); + yAxisRight.setDrawGridLines(false); + yAxisRight.setDrawAxisLine(true); + + } + + ValueFormatter getBodyEnergyChartXValueFormatter() { + return new ValueFormatter() { + @Override + public String getFormattedValue(float value) { + long timestamp = (long) (value * 1000); + Date date = new Date (); + date.setTime(timestamp); + SimpleDateFormat df = new SimpleDateFormat("HH:mm", Locale.getDefault()); + df.setTimeZone(TimeZone.getTimeZone("UTC")); + return df.format(date); + } + }; + } + + protected static class BodyEnergyData extends ChartsData { + private final List samples; + + protected BodyEnergyData(List samples) { + this.samples = samples; + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_body_energy.xml b/app/src/main/res/layout/fragment_body_energy.xml new file mode 100644 index 000000000..77630a125 --- /dev/null +++ b/app/src/main/res/layout/fragment_body_energy.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 113426168..6fe89f18b 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -3012,6 +3012,7 @@ @string/weeksleepchart_sleep_a_week_or_month @string/weekstepschart_steps_a_week_or_month @string/pref_header_hrv_status + @string/body_energy @string/menuitem_stress @string/menuitem_pai @string/stats_title @@ -3027,6 +3028,7 @@ @string/p_sleep_week @string/p_steps_week @string/p_hrv_status + @string/p_body_energy @string/p_stress @string/p_pai @string/p_speed_zones @@ -3042,6 +3044,7 @@ @string/p_sleep @string/p_sleep_week @string/p_hrv_status + @string/p_body_energy @string/p_steps_week @string/p_stress @string/p_pai diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 069a3013a..4c98bbde0 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -48,8 +48,10 @@ #fc5203 #be03fc #d12a2a + #5ac234 #858585 + #383838 #FFEDEDED #545254 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4c9e2fc98..d98e8bccb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1455,6 +1455,9 @@ %1$d ms %1$d-%2$d ms Baseline + Gained + Lost + Body Energy Level Biking Treadmill Exercise @@ -2319,6 +2322,7 @@ Stress Blood Oxygen HRV Status + Body Energy Ambient Sound Control Sound Control Device Information diff --git a/app/src/main/res/values/values.xml b/app/src/main/res/values/values.xml index 3dc107529..2015c8d7b 100644 --- a/app/src/main/res/values/values.xml +++ b/app/src/main/res/values/values.xml @@ -106,6 +106,7 @@ pai speedzones hrvstatus + bodyenergy livestats spo2 temperature