From 7c25ec2b6d8791d8eba04101e4176fa7f8eee3aa Mon Sep 17 00:00:00 2001 From: trentsuzuki Date: Wed, 3 Dec 2025 10:47:28 -0500 Subject: [PATCH] Add weekly/monthly charts for body energy --- .../charts/ActivityChartsActivity.java | 2 +- .../charts/BodyEnergyCollectionFragment.java | 27 ++ .../charts/BodyEnergyPeriodFragment.java | 292 ++++++++++++++++++ .../adapter/BodyEnergyFragmentAdapter.java | 29 ++ .../layout/fragment_body_energy_period.xml | 82 +++++ 5 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BodyEnergyCollectionFragment.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BodyEnergyPeriodFragment.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/BodyEnergyFragmentAdapter.java create mode 100644 app/src/main/res/layout/fragment_body_energy_period.xml 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 d870000217..f8e3ea2c5c 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 @@ -536,7 +536,7 @@ public class ActivityChartsActivity extends AbstractGBActivity implements Charts case "hrvstatus": return new HRVStatusFragment(); case "bodyenergy": - return new BodyEnergyFragment(); + return BodyEnergyCollectionFragment.newInstance(enabledTabsList.size() == 1); case "vo2max": return new VO2MaxFragment(); case "load": diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BodyEnergyCollectionFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BodyEnergyCollectionFragment.java new file mode 100644 index 0000000000..910f9ab60e --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BodyEnergyCollectionFragment.java @@ -0,0 +1,27 @@ +package nodomain.freeyourgadget.gadgetbridge.activities.charts; + +import android.os.Bundle; + +import androidx.viewpager2.adapter.FragmentStateAdapter; + +import nodomain.freeyourgadget.gadgetbridge.adapter.BodyEnergyFragmentAdapter; + +public class BodyEnergyCollectionFragment extends AbstractCollectionFragment { + public BodyEnergyCollectionFragment() { + + } + + public static BodyEnergyCollectionFragment newInstance(final boolean allowSwipe) { + final BodyEnergyCollectionFragment fragment = new BodyEnergyCollectionFragment(); + final Bundle args = new Bundle(); + args.putBoolean(ARG_ALLOW_SWIPE, allowSwipe); + fragment.setArguments(args); + return fragment; + } + + @Override + public FragmentStateAdapter getFragmentAdapter() { + return new BodyEnergyFragmentAdapter(this); + } +} + diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BodyEnergyPeriodFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BodyEnergyPeriodFragment.java new file mode 100644 index 0000000000..f5d4699b94 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/BodyEnergyPeriodFragment.java @@ -0,0 +1,292 @@ +package nodomain.freeyourgadget.gadgetbridge.activities.charts; + +import android.graphics.Paint; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.core.content.ContextCompat; + +import com.github.mikephil.charting.charts.CandleStickChart; +import com.github.mikephil.charting.charts.Chart; +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.CandleData; +import com.github.mikephil.charting.data.CandleDataSet; +import com.github.mikephil.charting.data.CandleEntry; +import com.github.mikephil.charting.formatter.ValueFormatter; + +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 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; +import nodomain.freeyourgadget.gadgetbridge.util.Accumulator; +import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; + +public class BodyEnergyPeriodFragment extends AbstractChartFragment { + protected static final Logger LOG = LoggerFactory.getLogger(BodyEnergyPeriodFragment.class); + + static int SEC_PER_DAY = 24 * 60 * 60; + static int DATA_INVALID = -1; + + private int BACKGROUND_COLOR; + private int CHART_TEXT_COLOR; + private int LEGEND_TEXT_COLOR; + private int BODY_ENERGY_COLOR; + + private TextView mDateView; + private TextView bodyEnergyMinimum; + private TextView bodyEnergyMaximum; + private CandleStickChart bodyEnergyChart; + private int TOTAL_DAYS; + + @Override + protected boolean isSingleDay() { + return false; + } + + public static BodyEnergyPeriodFragment newInstance(int totalDays) { + BodyEnergyPeriodFragment fragment = new BodyEnergyPeriodFragment(); + Bundle args = new Bundle(); + args.putInt("totalDays", totalDays); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + TOTAL_DAYS = getArguments() != null ? getArguments().getInt("totalDays") : 7; + } + + @Override + protected void init() { + BACKGROUND_COLOR = GBApplication.getBackgroundColor(requireContext()); + LEGEND_TEXT_COLOR = GBApplication.getTextColor(requireContext()); + CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext()); + BODY_ENERGY_COLOR = ContextCompat.getColor(getContext(), R.color.body_energy_level_color); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View rootView = inflater.inflate(R.layout.fragment_body_energy_period, container, false); + + rootView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> { + getChartsHost().enableSwipeRefresh(scrollY == 0); + }); + + mDateView = rootView.findViewById(R.id.date_view); + bodyEnergyMinimum = rootView.findViewById(R.id.body_energy_minimum); + bodyEnergyMaximum = rootView.findViewById(R.id.body_energy_maximum); + bodyEnergyChart = rootView.findViewById(R.id.body_energy_chart); + + setupChart(); + refresh(); + setupLegend(bodyEnergyChart); + + return rootView; + } + + @Override + public String getTitle() { + return getString(R.string.body_energy); + } + + private int getStartTs() { + Calendar day = Calendar.getInstance(); + day.setTime(getEndDate()); + day.set(Calendar.HOUR_OF_DAY, 0); + day.set(Calendar.MINUTE, 0); + day.set(Calendar.SECOND, 0); + return (int) (day.getTimeInMillis() / 1000) - SEC_PER_DAY * (TOTAL_DAYS - 1); + } + + private BodyEnergyDayData fetchBodyEnergyDataForDay(DBHandler db, GBDevice device, int startTs) { + int endTs = startTs + SEC_PER_DAY - 1; + List samples = getSamples(db, device, startTs, endTs); + + final Accumulator accumulator = new Accumulator(); + for (final BodyEnergySample sample : samples) { + if (sample.getEnergy() > 0) { + accumulator.add(sample.getEnergy()); + } + } + + final int minimum = accumulator.getCount() > 0 ? (int) Math.round(accumulator.getMin()) : DATA_INVALID; + final int maximum = accumulator.getCount() > 0 ? (int) Math.round(accumulator.getMax()) : DATA_INVALID; + + return new BodyEnergyDayData(samples, minimum, maximum); + } + + @Override + protected BodyEnergyPeriodData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) { + final int startTs = getStartTs(); + + List result = new ArrayList<>(); + for (int i = 0; i < TOTAL_DAYS; i++) { + BodyEnergyDayData dayData = fetchBodyEnergyDataForDay(db, device, startTs + i * SEC_PER_DAY); + result.add(dayData); + } + return new BodyEnergyPeriodData(result); + } + + private List getSamples(DBHandler db, GBDevice device, int startTs, int endTs) { + final DeviceCoordinator coordinator = device.getDeviceCoordinator(); + final TimeSampleProvider sampleProvider = coordinator.getBodyEnergySampleProvider(device, db.getDaoSession()); + return sampleProvider.getAllSamples(startTs * 1000L, endTs * 1000L); + } + + @Override + protected void updateChartsnUIThread(BodyEnergyPeriodData data) { + final int startTs = getStartTs(); + mDateView.setText(DateTimeUtils.formatDaysUntil(TOTAL_DAYS, getTSEnd())); + + final Accumulator minAccumulator = new Accumulator(); + final Accumulator maxAccumulator = new Accumulator(); + + final ArrayList candleEntries = new ArrayList<>(); + + for (int i = 0; i < data.days.size(); i++) { + final BodyEnergyDayData dayData = data.days.get(i); + if (dayData.minimum > 0 && dayData.maximum > 0) { + minAccumulator.add(dayData.minimum); + maxAccumulator.add(dayData.maximum); + candleEntries.add(new CandleEntry(i, dayData.maximum, dayData.minimum, dayData.minimum, dayData.maximum)); + } + } + + final String emptyValue = requireContext().getString(R.string.stats_empty_value); + final int minimum = minAccumulator.getCount() > 0 ? (int) Math.round(minAccumulator.getMin()) : DATA_INVALID; + final int maximum = maxAccumulator.getCount() > 0 ? (int) Math.round(maxAccumulator.getMax()) : DATA_INVALID; + + bodyEnergyMinimum.setText(minimum > 0 ? String.valueOf(minimum) : emptyValue); + bodyEnergyMaximum.setText(maximum > 0 ? String.valueOf(maximum) : emptyValue); + + final String fmt = TOTAL_DAYS == 7 ? "EEE" : "dd"; + SimpleDateFormat formatDay = new SimpleDateFormat(fmt, Locale.getDefault()); + ValueFormatter formatter = new ValueFormatter() { + @Override + public String getFormattedValue(float value) { + int dayIndex = Math.round(value); + if (dayIndex < 0 || dayIndex >= TOTAL_DAYS) { + return ""; + } + int ts = startTs + SEC_PER_DAY * dayIndex; + return formatDay.format(new Date(ts * 1000L)); + } + }; + bodyEnergyChart.getXAxis().setValueFormatter(formatter); + + CandleDataSet candleDataSet = new CandleDataSet(candleEntries, getString(R.string.body_energy)); + candleDataSet.setDrawValues(false); + candleDataSet.setDrawIcons(false); + candleDataSet.setAxisDependency(YAxis.AxisDependency.LEFT); + candleDataSet.setShadowColor(BODY_ENERGY_COLOR); + candleDataSet.setShadowWidth(2f); + candleDataSet.setDecreasingColor(BODY_ENERGY_COLOR); + candleDataSet.setDecreasingPaintStyle(Paint.Style.FILL); + candleDataSet.setIncreasingColor(BODY_ENERGY_COLOR); + candleDataSet.setIncreasingPaintStyle(Paint.Style.FILL); + candleDataSet.setNeutralColor(BODY_ENERGY_COLOR); + candleDataSet.setBarSpace(0.3f); + candleDataSet.setShowCandleBar(true); + + CandleData candleData = new CandleData(candleDataSet); + bodyEnergyChart.setData(candleData); + } + + private void setupChart() { + bodyEnergyChart.setBackgroundColor(BACKGROUND_COLOR); + bodyEnergyChart.getDescription().setEnabled(false); + + if (TOTAL_DAYS <= 7) { + 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.setGranularity(1f); + xAxisBottom.setGranularityEnabled(true); + xAxisBottom.setAxisMinimum(-0.5f); + xAxisBottom.setAxisMaximum(TOTAL_DAYS - 0.5f); + + final YAxis yAxisLeft = bodyEnergyChart.getAxisLeft(); + yAxisLeft.setDrawGridLines(true); + yAxisLeft.setAxisMaximum(100f); + yAxisLeft.setAxisMinimum(0f); + yAxisLeft.setDrawTopYLabelEntry(true); + yAxisLeft.setTextColor(CHART_TEXT_COLOR); + yAxisLeft.setEnabled(true); + yAxisLeft.setGranularity(10f); + yAxisLeft.setGranularityEnabled(true); + + final YAxis yAxisRight = bodyEnergyChart.getAxisRight(); + yAxisRight.setEnabled(true); + yAxisRight.setDrawLabels(false); + yAxisRight.setDrawGridLines(false); + yAxisRight.setDrawAxisLine(true); + } + + @Override + protected void setupLegend(Chart chart) { + List legendEntries = new ArrayList<>(1); + + LegendEntry rangeEntry = new LegendEntry(); + rangeEntry.label = getString(R.string.body_energy); + rangeEntry.formColor = BODY_ENERGY_COLOR; + legendEntries.add(rangeEntry); + + bodyEnergyChart.getLegend().setCustom(legendEntries); + bodyEnergyChart.getLegend().setTextColor(LEGEND_TEXT_COLOR); + bodyEnergyChart.getLegend().setWordWrapEnabled(true); + } + + @Override + protected void renderCharts() { + bodyEnergyChart.invalidate(); + } + + protected static class BodyEnergyPeriodData extends ChartsData { + public List days; + + protected BodyEnergyPeriodData(List days) { + this.days = days; + } + } + + protected static class BodyEnergyDayData extends ChartsData { + public List samples; + public int minimum; + public int maximum; + + protected BodyEnergyDayData(List samples, int minimum, int maximum) { + this.samples = samples; + this.minimum = minimum; + this.maximum = maximum; + } + } +} + diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/BodyEnergyFragmentAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/BodyEnergyFragmentAdapter.java new file mode 100644 index 0000000000..74ad2d04df --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/BodyEnergyFragmentAdapter.java @@ -0,0 +1,29 @@ +package nodomain.freeyourgadget.gadgetbridge.adapter; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; + +import nodomain.freeyourgadget.gadgetbridge.activities.charts.BodyEnergyFragment; +import nodomain.freeyourgadget.gadgetbridge.activities.charts.BodyEnergyPeriodFragment; + +public class BodyEnergyFragmentAdapter extends NestedFragmentAdapter { + + public BodyEnergyFragmentAdapter(Fragment fragment) { + super(fragment); + } + + @NonNull + @Override + public Fragment createFragment(int position) { + switch (position) { + case 0: + return new BodyEnergyFragment(); + case 1: + return BodyEnergyPeriodFragment.newInstance(7); + case 2: + return BodyEnergyPeriodFragment.newInstance(30); + } + return new BodyEnergyFragment(); + } +} + diff --git a/app/src/main/res/layout/fragment_body_energy_period.xml b/app/src/main/res/layout/fragment_body_energy_period.xml new file mode 100644 index 0000000000..fbb1423869 --- /dev/null +++ b/app/src/main/res/layout/fragment_body_energy_period.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +