Charts: Build fragment stats programatically

This would allow us to easily append further stats from device-specific
charts providers.
This commit is contained in:
José Rebelo
2026-01-20 22:27:18 +00:00
parent d47b0f99a6
commit 7dd319f760
7 changed files with 117 additions and 152 deletions
@@ -6,10 +6,16 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication
import nodomain.freeyourgadget.gadgetbridge.R
import nodomain.freeyourgadget.gadgetbridge.activities.charts.ActivityChartsActivity.UnknownFragment
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryEntry
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
import java.util.Locale
object DefaultChartsProvider : DeviceChartsProvider {
open class DefaultChartsProvider : DeviceChartsProvider {
companion object {
@JvmField
val INSTANCE = DefaultChartsProvider()
}
override fun getSupportedCharts(device: GBDevice): List<String> {
val coordinator = device.deviceCoordinator
val supportedCharts = mutableListOf<String>()
@@ -11,7 +11,6 @@ import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.core.content.ContextCompat;
import androidx.gridlayout.widget.GridLayout;
import com.github.mikephil.charting.charts.Chart;
import com.github.mikephil.charting.charts.LineChart;
@@ -40,7 +39,6 @@ import java.util.Locale;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.HeartRateUtils;
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryEntry;
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummarySimpleEntry;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
@@ -130,19 +128,19 @@ public class HeartRatePeriodFragment extends AbstractChartFragment<HeartRatePeri
@Override
protected void init() {
Prefs prefs = GBApplication.getPrefs();
CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(getContext());
DESCRIPTION_COLOR = LEGEND_TEXT_COLOR = TEXT_COLOR = GBApplication.getTextColor(getContext());
CHART_TEXT_COLOR = GBApplication.getSecondaryTextColor(requireContext());
DESCRIPTION_COLOR = LEGEND_TEXT_COLOR = TEXT_COLOR = GBApplication.getTextColor(requireContext());
if (prefs.getBoolean("chart_heartrate_color", false)) {
HEARTRATE_COLOR = ContextCompat.getColor(getContext(), R.color.chart_heartrate_alternative);
HEARTRATE_COLOR = ContextCompat.getColor(requireContext(), R.color.chart_heartrate_alternative);
} else {
HEARTRATE_COLOR = ContextCompat.getColor(getContext(), R.color.chart_heartrate);
HEARTRATE_COLOR = ContextCompat.getColor(requireContext(), R.color.chart_heartrate);
}
HEARTRATE_MIN_COLOR = ContextCompat.getColor(getContext(), R.color.chart_heartrate_minimum);
HEARTRATE_MAX_COLOR = ContextCompat.getColor(getContext(), R.color.chart_heartrate_maximum);
HEARTRATE_RESTING_COLOR = ContextCompat.getColor(getContext(), R.color.chart_heartrate_resting);
HEARTRATE_MIN_COLOR = ContextCompat.getColor(requireContext(), R.color.chart_heartrate_minimum);
HEARTRATE_MAX_COLOR = ContextCompat.getColor(requireContext(), R.color.chart_heartrate_maximum);
HEARTRATE_RESTING_COLOR = ContextCompat.getColor(requireContext(), R.color.chart_heartrate_resting);
}
private HeartRateData fetchHeartRateDataForDay(ChartsHost chartsHost, DBHandler db, GBDevice device, int startTs) {
private HeartRateData fetchHeartRateDataForDay(DBHandler db, GBDevice device, int startTs) {
int endTs = startTs + SEC_PER_DAY - 1;
List<? extends ActivitySample> samples = getActivitySamples(db, device, startTs, endTs);
final HeartRateUtils heartRateUtilsInstance = HeartRateUtils.getInstance();
@@ -180,7 +178,7 @@ public class HeartRatePeriodFragment extends AbstractChartFragment<HeartRatePeri
List<HeartRateData> result = new ArrayList<>();
for (int i = 0; i < TOTAL_DAYS; i++) {
HeartRateData dayData = fetchHeartRateDataForDay(chartsHost, db, device, startTs + i * SEC_PER_DAY);
HeartRateData dayData = fetchHeartRateDataForDay(db, device, startTs + i * SEC_PER_DAY);
result.add(dayData);
}
return new HeartRatePeriodData(result);
@@ -293,36 +291,31 @@ public class HeartRatePeriodFragment extends AbstractChartFragment<HeartRatePeri
private void setStatistics(int average, int minimum, int maximum, int resting) {
hrStatsContainer.removeAllViews();
final List<Pair<String, ActivitySummaryEntry>> entries = new ArrayList<>();
final GridTableBuilder builder = new GridTableBuilder(requireContext());
final ActivitySummarySimpleEntry empty = new ActivitySummarySimpleEntry("-", "string");
entries.add(Pair.of(
builder.addEntry(
getString(R.string.hr_minimum),
minimum > 0 ? new ActivitySummarySimpleEntry(minimum, UNIT_BPM) : empty
));
minimum > 0 ? new ActivitySummarySimpleEntry(minimum, UNIT_BPM) : null
);
entries.add(Pair.of(
builder.addEntry(
getString(R.string.hr_maximum),
maximum > 0 ? new ActivitySummarySimpleEntry(maximum, UNIT_BPM) : empty
));
maximum > 0 ? new ActivitySummarySimpleEntry(maximum, UNIT_BPM) : null
);
entries.add(Pair.of(
builder.addEntry(
getString(R.string.hr_average),
average > 0 ? new ActivitySummarySimpleEntry(average, UNIT_BPM) : empty
));
average > 0 ? new ActivitySummarySimpleEntry(average, UNIT_BPM) : null
);
if (supportsHeartRateRestingMeasurement()) {
entries.add(Pair.of(
builder.addEntry(
getString(R.string.hr_resting),
resting > 0 ? new ActivitySummarySimpleEntry(resting, UNIT_BPM) : empty
));
resting > 0 ? new ActivitySummarySimpleEntry(resting, UNIT_BPM) : null
);
}
// Build and add the grid
final GridTableBuilder builder = new GridTableBuilder(requireContext());
final GridLayout gridLayout = builder.buildGridLayout(entries);
hrStatsContainer.addView(gridLayout);
hrStatsContainer.addView(builder.build());
if (minimum > 0) {
hrLineChart.getAxisLeft().setAxisMinimum(Math.max(minimum - 30, 0));
@@ -17,6 +17,8 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.charts;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_BPM;
import android.content.Context;
import android.content.Intent;
import android.graphics.Color;
@@ -56,6 +58,7 @@ import java.util.concurrent.TimeUnit;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.HeartRateUtils;
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummarySimpleEntry;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.SleepAnalysis.SleepSession;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.sleep.AbstractOverlayData;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.sleep.OverlayDataFloat;
@@ -78,6 +81,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.TemperatureSample;
import nodomain.freeyourgadget.gadgetbridge.model.TimeSample;
import nodomain.freeyourgadget.gadgetbridge.util.Accumulator;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GridTableBuilder;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
@@ -420,10 +424,34 @@ public class SleepDailyFragment extends SleepFragment<SleepDailyFragment.MyChart
int heartRateMax = mcd.getHeartRateAxisMax();
int heartRateAvg = Math.round(mcd.getHeartRateAverage());
float intensityTotal = mcd.getIntensityTotal();
binding.sleepHrLowest.setText(String.valueOf(heartRateMin > 0 ? heartRateMin : "-"));
binding.sleepHrHighest.setText(String.valueOf(heartRateMax > 0 ? heartRateMax : "-"));
binding.sleepHrAverage.setText(String.valueOf(heartRateAvg > 0 ? heartRateAvg : "-"));
binding.sleepMovementIntensity.setText(intensityTotal > 0 ? new DecimalFormat("###.#").format(intensityTotal) : "-");
// Build stats grid programmatically
binding.sleepStatsContainer.removeAllViews();
final GridTableBuilder statsBuilder = new GridTableBuilder(requireContext());
statsBuilder.addEntry(
getString(R.string.minHR),
heartRateMin > 0 ? new ActivitySummarySimpleEntry(heartRateMin, UNIT_BPM) : null
);
statsBuilder.addEntry(
getString(R.string.maxHR),
heartRateMax > 0 ? new ActivitySummarySimpleEntry(heartRateMax, UNIT_BPM) : null
);
statsBuilder.addEntry(
getString(R.string.averageHR),
heartRateAvg > 0 ? new ActivitySummarySimpleEntry(heartRateAvg, UNIT_BPM) : null
);
if (intensityTotal > 0) {
statsBuilder.addEntry(
getString(R.string.movement_intensity),
new ActivitySummarySimpleEntry(new DecimalFormat("###.#").format(intensityTotal), "string")
);
}
binding.sleepStatsContainer.addView(statsBuilder.build());
if (supportsHeartrate(getChartsHost().getDevice()) && SHOW_CHARTS_AVERAGE) {
if (mcd.getHeartRateAxisMax() != 0 || mcd.getHeartRateAxisMin() != 0) {
@@ -329,11 +329,10 @@ class WorkoutDetailsFragment : Fragment(), MenuProvider {
private fun addGroupContent(entries: List<Pair<String, ActivitySummaryEntry>>) {
val gridTableBuilder = GridTableBuilder(requireContext(), workoutValueFormatter)
// Map keys from string resource names to actual human-readable labels
val mappedLabels = entries.map { Pair.of(workoutValueFormatter.getStringResourceByName(it.key), it.value) }
.toList()
val gridLayout = gridTableBuilder.buildGridLayout(mappedLabels)
binding.summaryDetails.addView(gridLayout)
for ((key, entry) in entries) {
gridTableBuilder.addEntry(workoutValueFormatter.getStringResourceByName(key), entry)
}
binding.summaryDetails.addView(gridTableBuilder.build())
}
@Suppress("SameParameterValue")
@@ -12,6 +12,8 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.WorkoutValueFormatter;
public class ActivitySummarySimpleEntry extends ActivitySummaryEntry {
public static final ActivitySummarySimpleEntry EMPTY = new ActivitySummarySimpleEntry("-", "string");
private final Object value;
private final String unit;
@@ -20,6 +20,7 @@ import android.content.Context
import android.view.Gravity
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.core.content.ContextCompat
import androidx.core.view.isNotEmpty
import androidx.gridlayout.widget.GridLayout
import nodomain.freeyourgadget.gadgetbridge.GBApplication
@@ -27,49 +28,66 @@ import nodomain.freeyourgadget.gadgetbridge.R
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.WorkoutValueFormatter
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryEntry
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummarySimpleEntry
import org.apache.commons.lang3.tuple.Pair
class GridTableBuilder @JvmOverloads constructor(
private val context: Context,
private val workoutValueFormatter: WorkoutValueFormatter = WorkoutValueFormatter()
) {
fun buildGridLayout(entries: List<Pair<String, ActivitySummaryEntry>>): GridLayout {
val gridLayout = GridLayout(context).apply {
setBackgroundColor(resources.getColor(R.color.gauge_line_color))
columnCount = 2
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
private var cellNumber = 0
private val columnSpans = mutableListOf<Int>()
private val gridLayout = GridLayout(context).apply {
setBackgroundColor(ContextCompat.getColor(context, R.color.gauge_line_color))
columnCount = 2
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
fun addEntry(key: String, entry: ActivitySummaryEntry?) {
val entry = entry ?: ActivitySummarySimpleEntry.EMPTY
val columnSpan = entry.columnSpan
if (columnSpan == 2 && cellNumber % 2 != 0) {
cellNumber++
}
val totalCells = entries.sumOf { it.right.columnSpan }.let { it + (it and 1) }
var cellNumber = 0
val linearLayout = generateLinearLayout(cellNumber, columnSpan)
entry.populate(key, linearLayout, workoutValueFormatter)
gridLayout.addView(linearLayout)
columnSpans.add(columnSpan)
cellNumber += columnSpan
}
for ((key, entry) in entries) {
val columnSpan = entry.columnSpan
if (columnSpan == 2 && cellNumber % 2 != 0) {
cellNumber++
}
val linearLayout = generateLinearLayout(cellNumber, cellNumber + 2 >= totalCells, columnSpan)
entry.populate(key, linearLayout, workoutValueFormatter)
gridLayout.addView(linearLayout)
cellNumber += columnSpan
fun build(): GridLayout {
if (gridLayout.isNotEmpty() && cellNumber % 2 != 0) {
// When in an odd number of cells, add an empty one to prevent a gray hole from showing up
val emptyLayout = generateLinearLayout(cellNumber, 1)
ActivitySummarySimpleEntry("", "string").populate("", emptyLayout, workoutValueFormatter)
gridLayout.addView(emptyLayout)
columnSpans.add(1)
}
// Then, adjust the bottom margin for the last row
var adjustedColumns = 0
if (gridLayout.isNotEmpty()) {
if (cellNumber % 2 != 0) {
val emptyLayout = generateLinearLayout(cellNumber, true, 1)
ActivitySummarySimpleEntry(null, "", "string").populate("", emptyLayout, workoutValueFormatter)
gridLayout.addView(emptyLayout)
for (i in gridLayout.childCount - 1 downTo 0) {
val layoutParams = gridLayout.getChildAt(i).layoutParams
if (layoutParams is GridLayout.LayoutParams) {
layoutParams.bottomMargin = dpToPx(2)
adjustedColumns += columnSpans[i]
if (adjustedColumns >= gridLayout.columnCount) {
break
}
}
}
}
return gridLayout
}
private fun generateLinearLayout(i: Int, lastRow: Boolean, columnSize: Int): LinearLayout {
private fun generateLinearLayout(i: Int, columnSize: Int): LinearLayout {
return LinearLayout(context).apply {
val layoutParams = GridLayout.LayoutParams(
GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f),
@@ -85,7 +103,7 @@ class GridTableBuilder @JvmOverloads constructor(
val marginLeft = if (i % 2 == 0) 0 else 1
val marginRight = if (i % 2 == 0) 1 else 0
val marginTop = 2
val marginBottom = if (lastRow) 2 else 0
val marginBottom = 0 // will be changed to 2 for the last row
layoutParams.setMargins(dpToPx(marginLeft), dpToPx(marginTop), dpToPx(marginRight), dpToPx(marginBottom))
}
@@ -192,93 +192,12 @@
android:layout_marginEnd="10dp"
android:layout_marginBottom="10dp"/>
<GridLayout
android:background="@color/gauge_line_color"
<LinearLayout
android:id="@+id/sleep_stats_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:columnCount="2"
android:rowCount="2"
android:layout_height="wrap_content"
android:layout_marginBottom="15dp"
>
<LinearLayout
android:layout_marginTop="2dp"
android:layout_marginEnd="1dp"
style="@style/GridTile"
>
<TextView
android:id="@+id/sleep_hr_lowest"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/stats_empty_value"
android:textColor="?attr/textColorPrimary"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/minHR"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:layout_marginTop="2dp"
android:layout_marginStart="1dp"
style="@style/GridTile"
>
<TextView
android:id="@+id/sleep_hr_highest"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/stats_empty_value"
android:textColor="?attr/textColorPrimary"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/maxHR"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:layout_marginBottom="2dp"
android:layout_marginEnd="1dp"
style="@style/GridTile"
>
<TextView
android:id="@+id/sleep_hr_average"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/stats_empty_value"
android:textColor="?attr/textColorPrimary"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/averageHR"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/sleep_chart_legend_movement_intensity_wrapper"
android:layout_marginBottom="2dp"
android:layout_marginEnd="1dp"
style="@style/GridTile"
>
<TextView
android:id="@+id/sleep_movement_intensity"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/stats_empty_value"
android:textColor="?attr/textColorPrimary"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/movement_intensity"
android:textSize="12sp" />
</LinearLayout>
</GridLayout>
android:orientation="vertical"/>
<com.github.mikephil.charting.charts.LineChart
android:id="@+id/sleepchart"