Add weekly/monthly views for calories charts

This commit is contained in:
trentsuzuki
2025-05-10 09:15:09 +00:00
committed by José Rebelo
parent 303d01530f
commit d07d4865d1
7 changed files with 596 additions and 4 deletions
@@ -17,7 +17,6 @@
package nodomain.freeyourgadget.gadgetbridge.activities.charts;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
@@ -196,9 +195,7 @@ public class ActivityChartsActivity extends AbstractChartsActivity {
case "weight":
return new WeightChartFragment();
case "calories":
Intent intent = getIntent();
String mode = intent.getStringExtra(ActivityChartsActivity.EXTRA_MODE);
return CaloriesDailyFragment.newInstance(mode);
return CaloriesCollectionFragment.newInstance(enabledTabsList.size() == 1);
case "respiratoryrate":
return RespiratoryRateCollectionFragment.newInstance(enabledTabsList.size() == 1);
}
@@ -0,0 +1,45 @@
/* Copyright (C) 2024 a0z, José Rebelo
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.os.Bundle;
import androidx.fragment.app.FragmentManager;
import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBFragment;
import nodomain.freeyourgadget.gadgetbridge.adapter.CaloriesFragmentAdapter;
import nodomain.freeyourgadget.gadgetbridge.adapter.NestedFragmentAdapter;
public class CaloriesCollectionFragment extends AbstractCollectionFragment {
public CaloriesCollectionFragment() {
}
public static CaloriesCollectionFragment newInstance(final boolean allowSwipe) {
final CaloriesCollectionFragment fragment = new CaloriesCollectionFragment();
final Bundle args = new Bundle();
args.putBoolean(ARG_ALLOW_SWIPE, allowSwipe);
fragment.setArguments(args);
return fragment;
}
@Override
public NestedFragmentAdapter getNestedFragmentAdapter(AbstractGBFragment fragment, FragmentManager childFragmentManager) {
return new CaloriesFragmentAdapter(this, getChildFragmentManager());
}
}
@@ -0,0 +1,138 @@
package nodomain.freeyourgadget.gadgetbridge.activities.charts;
import android.app.Activity;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.DefaultRestingMetabolicRateProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityAmount;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityAmounts;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.RestingMetabolicRateSample;
import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue;
abstract class CaloriesFragment<T extends ChartsData> extends AbstractChartFragment<T> {
protected static final Logger LOG = LoggerFactory.getLogger(CaloriesFragment.class);
protected int CHART_TEXT_COLOR;
protected int TEXT_COLOR;
protected int BACKGROUND_COLOR;
protected int DESCRIPTION_COLOR;
protected int TOTAL_DAYS = 1;
@Override
public String getTitle() {
return getString(R.string.steps);
}
@Override
protected void init() {
TEXT_COLOR = GBApplication.getTextColor(requireContext());
CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext());
BACKGROUND_COLOR = GBApplication.getBackgroundColor(getContext());
DESCRIPTION_COLOR = GBApplication.getTextColor(getContext());
CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(getContext());
}
protected List<CaloriesFragment.CaloriesDay> getMyCaloriesDaysData(DBHandler db, Calendar day, GBDevice device) {
RestingMetabolicRateSample metabolicRate = getRestingMetabolicRate(db, device);
int restingCalories = metabolicRate.getRestingMetabolicRate();
day = (Calendar) day.clone(); // do not modify the caller's argument
day.add(Calendar.DATE, -TOTAL_DAYS + 1);
List<CaloriesDay> daysData = new ArrayList<>();
for (int counter = 0; counter < TOTAL_DAYS; counter++) {
long activeCalories = 0;
ActivityAmounts amounts = getActivityAmountsForDay(db, day, device);
for (ActivityAmount amount : amounts.getAmounts()) {
if (amount.getTotalActiveCalories() > 0) {
activeCalories += amount.getTotalActiveCalories() / 1000;
}
}
Calendar d = (Calendar) day.clone();
daysData.add(new CaloriesDay(d, activeCalories, restingCalories));
day.add(Calendar.DATE, 1);
}
return daysData;
}
protected RestingMetabolicRateSample getRestingMetabolicRate(DBHandler db, GBDevice device) {
TimeSampleProvider<? extends RestingMetabolicRateSample> provider = device.getDeviceCoordinator().getRestingMetabolicRateProvider(device, db.getDaoSession());
RestingMetabolicRateSample latestSample = provider.getLatestSample();
if (latestSample != null) {
return latestSample;
}
DefaultRestingMetabolicRateProvider defaultProvider = new DefaultRestingMetabolicRateProvider(device, db.getDaoSession());
return defaultProvider.getLatestSample();
}
protected ActivityAmounts getActivityAmountsForDay(DBHandler db, Calendar day, GBDevice device) {
LimitedQueue<Integer, ActivityAmounts> activityAmountCache = null;
ActivityAmounts amounts = null;
Activity activity = getActivity();
int key = (int) (day.getTimeInMillis() / 1000);
if (activity != null) {
activityAmountCache = ((ActivityChartsActivity) activity).mActivityAmountCache;
amounts = activityAmountCache.lookup(key);
}
if (amounts == null) {
ActivityAnalysis analysis = new ActivityAnalysis();
amounts = analysis.calculateActivityAmounts(getSamplesOfDay(db, day, 0, device));
if (activityAmountCache != null) {
activityAmountCache.add(key, amounts);
}
}
return amounts;
}
protected List<? extends ActivitySample> getSamplesOfDay(DBHandler db, Calendar day, int offsetHours, GBDevice device) {
int startTs;
int endTs;
day = (Calendar) day.clone(); // do not modify the caller's argument
day.set(Calendar.HOUR_OF_DAY, 0);
day.set(Calendar.MINUTE, 0);
day.set(Calendar.SECOND, 0);
day.add(Calendar.HOUR, offsetHours);
startTs = (int) (day.getTimeInMillis() / 1000);
endTs = startTs + 24 * 60 * 60 - 1;
return getSamples(db, device, startTs, endTs);
}
protected List<? extends ActivitySample> getSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
SampleProvider<? extends ActivitySample> provider = device.getDeviceCoordinator().getSampleProvider(device, db.getDaoSession());
return provider.getAllActivitySamples(tsFrom, tsTo);
}
protected static class CaloriesDay {
public long activeCalories;
public long restingCalories;
public Calendar day;
protected CaloriesDay(Calendar day, long activeCalories, long restingCalories) {
this.activeCalories = activeCalories;
this.restingCalories = restingCalories;
this.day = day;
}
}
}
@@ -0,0 +1,244 @@
package nodomain.freeyourgadget.gadgetbridge.activities.charts;
import android.content.Context;
import android.graphics.Color;
import android.os.Build;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import com.github.mikephil.charting.charts.BarChart;
import com.github.mikephil.charting.charts.Chart;
import com.github.mikephil.charting.components.LimitLine;
import com.github.mikephil.charting.components.XAxis;
import com.github.mikephil.charting.components.YAxis;
import com.github.mikephil.charting.data.BarData;
import com.github.mikephil.charting.data.BarDataSet;
import com.github.mikephil.charting.data.BarEntry;
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.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
public class CaloriesPeriodFragment extends CaloriesFragment<CaloriesPeriodFragment.CaloriesData> {
protected static final Logger LOG = LoggerFactory.getLogger(CaloriesPeriodFragment.class);
private TextView mDateView;
private TextView activeCaloriesAvg;
private TextView activeCaloriesTotal;
private TextView restingCaloriesAvg;
private TextView restingCaloriesTotal;
private BarChart caloriesChart;
private TextView mBalanceView;
protected int CHART_TEXT_COLOR;
protected int TEXT_COLOR;
protected int CALORIES_GOAL;
protected int BACKGROUND_COLOR;
protected int DESCRIPTION_COLOR;
public static CaloriesPeriodFragment newInstance(int totalDays) {
CaloriesPeriodFragment fragmentFirst = new CaloriesPeriodFragment();
Bundle args = new Bundle();
args.putInt("totalDays", totalDays);
fragmentFirst.setArguments(args);
return fragmentFirst;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
TOTAL_DAYS = getArguments() != null ? getArguments().getInt("totalDays") : 0;
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.fragment_calories_period, container, false);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
rootView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> {
getChartsHost().enableSwipeRefresh(scrollY == 0);
});
}
mDateView = rootView.findViewById(R.id.calories_date_view);
caloriesChart = rootView.findViewById(R.id.calories_chart);
activeCaloriesAvg = rootView.findViewById(R.id.active_calories_avg);
activeCaloriesTotal = rootView.findViewById(R.id.active_calories_total);
restingCaloriesAvg = rootView.findViewById(R.id.resting_calories_avg);
restingCaloriesTotal = rootView.findViewById(R.id.resting_calories_total);
CALORIES_GOAL = GBApplication.getPrefs().getInt(ActivityUser.PREF_USER_CALORIES_BURNT, ActivityUser.defaultUserCaloriesBurntGoal);
mBalanceView = rootView.findViewById(R.id.balance);
setupCaloriesChart();
refresh();
return rootView;
}
protected void setupCaloriesChart() {
caloriesChart.getDescription().setEnabled(false);
if (TOTAL_DAYS <= 7) {
caloriesChart.setTouchEnabled(false);
caloriesChart.setPinchZoom(false);
}
caloriesChart.setDoubleTapToZoomEnabled(false);
caloriesChart.getLegend().setEnabled(false);
final XAxis xAxisBottom = caloriesChart.getXAxis();
xAxisBottom.setPosition(XAxis.XAxisPosition.BOTTOM);
xAxisBottom.setDrawLabels(true);
xAxisBottom.setDrawGridLines(false);
xAxisBottom.setEnabled(true);
xAxisBottom.setDrawLimitLinesBehindData(true);
xAxisBottom.setTextColor(CHART_TEXT_COLOR);
final YAxis yAxisLeft = caloriesChart.getAxisLeft();
yAxisLeft.setDrawGridLines(true);
yAxisLeft.setDrawTopYLabelEntry(true);
yAxisLeft.setEnabled(true);
yAxisLeft.setTextColor(CHART_TEXT_COLOR);
yAxisLeft.setAxisMinimum(0f);
final LimitLine goalLine = new LimitLine(CALORIES_GOAL);
goalLine.setLineColor(getResources().getColor(R.color.calories_color));
goalLine.setLineWidth(1.5f);
goalLine.enableDashedLine(15f, 10f, 0f);
yAxisLeft.addLimitLine(goalLine);
final YAxis yAxisRight = caloriesChart.getAxisRight();
yAxisRight.setEnabled(true);
yAxisRight.setDrawLabels(false);
yAxisRight.setDrawGridLines(false);
yAxisRight.setDrawAxisLine(true);
}
@Override
public String getTitle() {
return getString(R.string.calories);
}
@Override
protected void init() {
TEXT_COLOR = GBApplication.getTextColor(requireContext());
CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext());
BACKGROUND_COLOR = GBApplication.getBackgroundColor(getContext());
DESCRIPTION_COLOR = GBApplication.getTextColor(getContext());
CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(getContext());
}
@Override
protected CaloriesData refreshInBackground(ChartsHost chartsHost, DBHandler db, GBDevice device) {
Calendar day = Calendar.getInstance();
day.setTime(getEndDate());
List<CaloriesDay> caloriesDaysData = getMyCaloriesDaysData(db, day, device);
return new CaloriesData(caloriesDaysData);
}
@Override
protected void updateChartsnUIThread(CaloriesData caloriesData) {
mDateView.setText(DateTimeUtils.formatDaysUntil(TOTAL_DAYS, getTSEnd()));
caloriesChart.setData(null);
List<BarEntry> entries = new ArrayList<>();
int counter = 0;
for (CaloriesDay day : caloriesData.days) {
entries.add(new BarEntry(counter, day.activeCalories));
counter++;
}
BarDataSet set = new BarDataSet(entries, "Calories");
set.setDrawValues(true);
set.setColors(getResources().getColor(R.color.calories_color));
final XAxis x = caloriesChart.getXAxis();
x.setValueFormatter(getCaloriesChartDayValueFormatter(caloriesData));
caloriesChart.getAxisLeft().setAxisMaximum((float) Math.max(set.getYMax() * 1.1, CALORIES_GOAL));
BarData barData = new BarData(set);
barData.setValueTextColor(Color.GRAY); //prevent tearing other graph elements with the black text. Another approach would be to hide the values cmpletely with data.setDrawValues(false);
barData.setValueTextSize(10f);
if (TOTAL_DAYS > 7) {
caloriesChart.setRenderer(new AngledLabelsChartRenderer(caloriesChart, caloriesChart.getAnimator(), caloriesChart.getViewPortHandler()));
}
caloriesChart.setData(barData);
activeCaloriesAvg.setText(String.format(String.valueOf(caloriesData.activeCaloriesDailyAvg)));
activeCaloriesTotal.setText(String.format(String.valueOf(caloriesData.totalActiveCalories)));
restingCaloriesAvg.setText(String.format(String.valueOf(caloriesData.restingCaloriesDailyAvg)));
restingCaloriesTotal.setText(String.format(String.valueOf(caloriesData.totalRestingCalories)));
mBalanceView.setText(caloriesData.getBalanceMessage(getContext(), CALORIES_GOAL));
}
ValueFormatter getCaloriesChartDayValueFormatter(CaloriesPeriodFragment.CaloriesData caloriesData) {
return new ValueFormatter() {
@Override
public String getFormattedValue(float value) {
CaloriesPeriodFragment.CaloriesDay day = caloriesData.days.get((int) value);
String pattern = TOTAL_DAYS > 7 ? "dd" : "EEE";
SimpleDateFormat formatLetterDay = new SimpleDateFormat(pattern, Locale.getDefault());
return formatLetterDay.format(new Date(day.day.getTimeInMillis()));
}
};
}
@Override
protected void renderCharts() {
caloriesChart.invalidate();
}
protected void setupLegend(Chart<?> chart) {
}
protected static class CaloriesData extends ChartsData {
List<CaloriesDay> days;
long activeCaloriesDailyAvg = 0;
long totalActiveCalories = 0;
long restingCaloriesDailyAvg = 0;
long totalRestingCalories = 0;
CaloriesDay todayCaloriesDay;
protected CaloriesData(List<CaloriesDay> days) {
this.days = days;
int daysCounter = days.size();
for (CaloriesDay day : days) {
this.totalActiveCalories += day.activeCalories;
this.totalRestingCalories += day.restingCalories;
}
if (daysCounter > 0) {
this.activeCaloriesDailyAvg = this.totalActiveCalories / daysCounter;
this.restingCaloriesDailyAvg = this.totalRestingCalories / daysCounter;
}
this.todayCaloriesDay = days.get(days.size() - 1);
}
protected String getBalanceMessage(final Context context, final int targetValue) {
if (totalActiveCalories == 0) {
return context.getString(R.string.no_data);
}
final long totalBalance = totalActiveCalories - ((long) targetValue * days.size());
if (totalBalance > 0) {
return context.getString(R.string.calorie_over_goal, Math.abs(totalBalance));
} else {
return context.getString(R.string.calorie_under_goal, Math.abs(totalBalance));
}
}
}
}
@@ -0,0 +1,32 @@
package nodomain.freeyourgadget.gadgetbridge.adapter;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBFragment;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.CaloriesDailyFragment;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.CaloriesPeriodFragment;
public class CaloriesFragmentAdapter extends NestedFragmentAdapter {
protected FragmentManager fragmentManager;
public CaloriesFragmentAdapter(AbstractGBFragment fragment, FragmentManager childFragmentManager) {
super(fragment, childFragmentManager);
fragmentManager = childFragmentManager;
}
@NonNull
@Override
public Fragment createFragment(int position) {
switch (position) {
case 0:
return new CaloriesDailyFragment();
case 1:
return CaloriesPeriodFragment.newInstance(7);
case 2:
return CaloriesPeriodFragment.newInstance(30);
}
return new CaloriesDailyFragment();
}
}
@@ -0,0 +1,131 @@
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/calories_date_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textSize="20sp"
android:layout_marginTop="15dp"
android:layout_marginBottom="10dp"
/>
</RelativeLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:gravity="center">
<TextView
android:id="@+id/balance"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="250sp"
>
<com.github.mikephil.charting.charts.BarChart
android:id="@+id/calories_chart"
android:layout_width="0dp"
android:layout_height="fill_parent"
android:layout_weight="2" />
</LinearLayout>
<GridLayout
android:background="@color/gauge_line_color"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:columnCount="2"
android:layout_marginTop="15dp"
>
<LinearLayout
style="@style/GridTile"
android:layout_marginEnd="1dp"
android:layout_marginTop="2dp"
>
<TextView
android:id="@+id/active_calories_avg"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/active_calories_avg"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
style="@style/GridTile"
android:layout_marginStart="1dp"
android:layout_marginTop="2dp"
>
<TextView
android:id="@+id/active_calories_total"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/active_calories_total"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
style="@style/GridTile"
android:layout_marginEnd="1dp"
>
<TextView
android:id="@+id/resting_calories_avg"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/metabolic_rate"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
style="@style/GridTile"
android:layout_marginStart="1dp"
>
<TextView
android:id="@+id/resting_calories_total"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/resting_calories_total"
android:textSize="12sp" />
</LinearLayout>
</GridLayout>
</LinearLayout>
</ScrollView>
+5
View File
@@ -2393,6 +2393,11 @@
<string name="caloriesBurnt">Calories</string>
<string name="restingCalories">Resting Calories</string>
<string name="metabolic_rate">Metabolic Rate</string>
<string name="active_calories_avg">Active AVG</string>
<string name="active_calories_total">Active Total</string>
<string name="resting_calories_total">Resting Total</string>
<string name="calorie_over_goal">Above goal: %1$d kcal</string>
<string name="calorie_under_goal">Below goal: %1$d kcal</string>
<string name="maxSpeed">Maximum</string>
<string name="minSpeed">Minimum</string>
<string name="minPace">Slowest Pace</string>