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 ff5f1993bf..05f66dc8d8 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 @@ -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); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CaloriesCollectionFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CaloriesCollectionFragment.java new file mode 100644 index 0000000000..3e0498f419 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CaloriesCollectionFragment.java @@ -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 . */ +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()); + } +} + diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CaloriesFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CaloriesFragment.java new file mode 100644 index 0000000000..e6d2fa33da --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CaloriesFragment.java @@ -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 extends AbstractChartFragment { + 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 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 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 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 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 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 getSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) { + SampleProvider 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; + } + } + + +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CaloriesPeriodFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CaloriesPeriodFragment.java new file mode 100644 index 0000000000..38989e9475 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/CaloriesPeriodFragment.java @@ -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 { + 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 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 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 days; + long activeCaloriesDailyAvg = 0; + long totalActiveCalories = 0; + long restingCaloriesDailyAvg = 0; + long totalRestingCalories = 0; + CaloriesDay todayCaloriesDay; + + protected CaloriesData(List 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)); + } + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/CaloriesFragmentAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/CaloriesFragmentAdapter.java new file mode 100644 index 0000000000..fbc56f5f08 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/CaloriesFragmentAdapter.java @@ -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(); + } +} diff --git a/app/src/main/res/layout/fragment_calories_period.xml b/app/src/main/res/layout/fragment_calories_period.xml new file mode 100644 index 0000000000..4a72a6aff9 --- /dev/null +++ b/app/src/main/res/layout/fragment_calories_period.xml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 01222ffb80..453ec4c3f6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2393,6 +2393,11 @@ Calories Resting Calories Metabolic Rate + Active AVG + Active Total + Resting Total + Above goal: %1$d kcal + Below goal: %1$d kcal Maximum Minimum Slowest Pace