Workout details: combined charts activity

This commit is contained in:
a0z
2025-08-28 19:10:07 +02:00
committed by José Rebelo
parent 594f775485
commit ec55ce3ac0
15 changed files with 347 additions and 10 deletions
+4
View File
@@ -198,6 +198,10 @@
android:name=".activities.maps.MapsTrackActivity"
android:label="@string/maps"
android:parentActivityName=".activities.SettingsActivity" />
<activity
android:name=".activities.workouts.charts.WorkoutChartsActivity"
android:label="@string/workout_charts"
android:parentActivityName=".activities.SettingsActivity" />
<activity
android:name=".activities.SleepAsAndroidPreferencesActivity"
android:label="@string/sleepasandroid_settings"
@@ -1,6 +1,6 @@
package nodomain.freeyourgadget.gadgetbridge.activities.charts;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_SECONDS_PER_KM;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_MINUTES_PER_KM;
import com.github.mikephil.charting.formatter.ValueFormatter;
@@ -17,7 +17,7 @@ public class SpeedYLabelFormatter extends ValueFormatter {
@Override
public String getFormattedValue(float value) {
if (unit.equals(UNIT_SECONDS_PER_KM)) {
if (unit.equals(UNIT_MINUTES_PER_KM)) {
value = value > 0 ? Math.round((60 / (value * 3.6)) * 60) : 0;
}
return workoutValueFormatter.formatValue(value, unit, false);
@@ -0,0 +1,91 @@
package nodomain.freeyourgadget.gadgetbridge.activities.charts.marker;
import android.content.Context;
import android.graphics.Canvas;
import android.widget.TextView;
import com.github.mikephil.charting.components.MarkerView;
import com.github.mikephil.charting.data.Entry;
import com.github.mikephil.charting.data.LineData;
import com.github.mikephil.charting.formatter.ValueFormatter;
import com.github.mikephil.charting.highlight.Highlight;
import com.github.mikephil.charting.interfaces.datasets.ILineDataSet;
import com.github.mikephil.charting.utils.MPPointF;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.R;
public class ValueMarker extends MarkerView {
private TextView markerContent;
private List<ValueFormatter> valueFormatters;
private List<String> valueUnits;
private LineData lineData;
public ValueMarker(Context context) {
super(context, R.layout.value_marker);
this.markerContent = findViewById(R.id.marker_content);
}
public ValueMarker(Context context, LineData lineData, List<ValueFormatter> valueFormatters, List<String> valueUnits) {
super(context, R.layout.value_marker);
this.markerContent = findViewById(R.id.marker_content);
this.valueFormatters = valueFormatters;
this.lineData = lineData;
this.valueUnits = valueUnits;
}
@Override
public void refreshContent(Entry e, Highlight highlight) {
float xVal = e.getX();
StringBuilder content = new StringBuilder();
for (int i = 0; i < lineData.getDataSetCount(); i++) {
ILineDataSet dataSet = lineData.getDataSetByIndex(i);
if (dataSet == null || !dataSet.isVisible()) {
continue;
}
Entry entryForX = dataSet.getEntryForXValue(xVal, Float.NaN);
if (entryForX != null) {
if (valueFormatters.get(i) != null) {
content.append(valueFormatters.get(i).getFormattedValue(entryForX.getY()));
} else {
content.append(entryForX.getY());
}
if (valueUnits.get(i) != null) {
content.append(" ");
content.append(valueUnits.get(i));
}
content.append("\n");
}
}
markerContent.setText(content.toString().trim());
super.refreshContent(e, highlight);
}
@Override
public void draw(Canvas canvas, float posX, float posY) {
// Stick the value marker box to the top.
MPPointF offset = getOffset();
float markerWidth = getWidth();
float offsetX = offset.x;
if ((posX + offsetX) < 0) {
posX = -offsetX;
} else if ((posX + offsetX + markerWidth) > canvas.getWidth()) {
posX = canvas.getWidth() - markerWidth - offsetX;
}
float fixedTopY = 10f;
canvas.translate(posX + offsetX, fixedTopY);
draw(canvas);
canvas.translate(-posX - offsetX, -fixedTopY);
}
@Override
public MPPointF getOffset() {
// Center horizontally and place above the marker point
return new MPPointF(-(getWidth() / 2f), -getHeight());
}
}
@@ -27,6 +27,7 @@ import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
@@ -46,7 +47,12 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import com.github.mikephil.charting.charts.LineChart
import com.github.mikephil.charting.components.XAxis
import com.github.mikephil.charting.data.Entry
import com.github.mikephil.charting.data.LineData
import com.github.mikephil.charting.highlight.Highlight
import com.github.mikephil.charting.listener.ChartTouchListener
import com.github.mikephil.charting.listener.OnChartGestureListener
import com.github.mikephil.charting.listener.OnChartValueSelectedListener
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -54,9 +60,13 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication
import nodomain.freeyourgadget.gadgetbridge.R
import nodomain.freeyourgadget.gadgetbridge.activities.ActivitySummariesChartFragment
import nodomain.freeyourgadget.gadgetbridge.activities.charts.DurationXLabelFormatter
import nodomain.freeyourgadget.gadgetbridge.activities.charts.marker.ValueMarker
import nodomain.freeyourgadget.gadgetbridge.activities.fit.FitViewerActivity
import nodomain.freeyourgadget.gadgetbridge.activities.maps.MapsTrackActivity
import nodomain.freeyourgadget.gadgetbridge.activities.maps.MapsTrackViewModel.Companion.getActivityPoints
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.charts.ChartDataRepository
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.charts.DefaultWorkoutCharts
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.charts.WorkoutChartsActivity
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryEntry
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryGroup
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummarySimpleEntry
@@ -282,7 +292,7 @@ class WorkoutDetailsFragment : Fragment(), MenuProvider {
}
groupCharts.forEach { chart ->
addChart(binding.summaryDetails, false, chart)
addChart(binding.summaryDetails, false, chart, workout.charts)
}
}
}
@@ -381,7 +391,7 @@ class WorkoutDetailsFragment : Fragment(), MenuProvider {
binding.dynamicCharts.removeAllViews()
for (chart in workout.charts) {
if (chart.group == null) {
addChart(binding.dynamicCharts, true, chart)
addChart(binding.dynamicCharts, true, chart, workout.charts)
}
}
@@ -396,7 +406,8 @@ class WorkoutDetailsFragment : Fragment(), MenuProvider {
private fun addChart(
chartsLayout: LinearLayout,
includeHeader: Boolean,
chart: WorkoutChart
chart: WorkoutChart,
allChartsData: List<WorkoutChart>
) {
if (includeHeader) {
val chartTitle = TextView(context).apply {
@@ -464,6 +475,22 @@ class WorkoutDetailsFragment : Fragment(), MenuProvider {
}
lineChart.data = chart.chartData as LineData?
lineChart.description.isEnabled = false;
lineChart.onChartGestureListener = object : OnChartGestureListener {
override fun onChartLongPressed(me: MotionEvent?) {}
override fun onChartGestureStart(me: MotionEvent?, lastPerformedGesture: ChartTouchListener.ChartGesture?) {}
override fun onChartGestureEnd(me: MotionEvent?, lastPerformedGesture: ChartTouchListener.ChartGesture?) {}
override fun onChartSingleTapped(me: MotionEvent?) {
ChartDataRepository.chartData = allChartsData
val intent = Intent(requireContext(), WorkoutChartsActivity::class.java).apply {
putExtra(WorkoutChartsActivity.INIT_CHART_ID, chart.id)
}
startActivity(intent)
}
override fun onChartDoubleTapped(me: MotionEvent?) {}
override fun onChartFling(me1: MotionEvent?, me2: MotionEvent?, velocityX: Float, velocityY: Float) {}
override fun onChartScale(me: MotionEvent?, scaleX: Float, scaleY: Float) {}
override fun onChartTranslate(me: MotionEvent?, dX: Float, dY: Float) {}
}
lineChart.invalidate()
chartsFragmentHolder.addView(lineChart)
@@ -6,6 +6,7 @@ import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_LB;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_METERS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_METERS_PER_SECOND;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_MINUTES_PER_KM;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_MM;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_SECONDS_PER_100_METERS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_SECONDS_PER_100_YARDS;
@@ -122,6 +123,7 @@ public class WorkoutValueFormatter {
}
break;
case UNIT_SECONDS_PER_KM:
case UNIT_MINUTES_PER_KM:
if (units.equals(UNIT_IMPERIAL)) {
value = value / 60D * 1.609344;
unit = "minutes_mi";
@@ -0,0 +1,11 @@
package nodomain.freeyourgadget.gadgetbridge.activities.workouts.charts
import nodomain.freeyourgadget.gadgetbridge.model.workout.WorkoutChart
object ChartDataRepository {
var chartData: List<WorkoutChart>? = null
fun clear() {
chartData = null
}
}
@@ -3,6 +3,7 @@ package nodomain.freeyourgadget.gadgetbridge.activities.workouts.charts;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_BPM;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_METERS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_METERS_PER_SECOND;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_MINUTES_PER_KM;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_SECONDS_PER_KM;
import android.content.Context;
@@ -12,6 +13,7 @@ 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 java.util.ArrayList;
import java.util.LinkedList;
@@ -56,25 +58,31 @@ public class DefaultWorkoutCharts {
if (!heartRateDataPoints.isEmpty()) {
final String label = String.format("%s(%s)", context.getString(R.string.heart_rate), getUnitString(context, UNIT_BPM));
final LineDataSet dataset = createDataSet(context, heartRateDataPoints, label, context.getResources().getColor(R.color.chart_line_heart_rate));
charts.add(new WorkoutChart(context.getString(R.string.heart_rate), ActivitySummaryEntries.GROUP_HEART_RATE, new LineData(dataset)));
ValueFormatter integerFormatter = new ValueFormatter() {
@Override
public String getFormattedValue(float value) {
return String.valueOf((int) value);
}
};
charts.add(new WorkoutChart("heart_rate", context.getString(R.string.heart_rate), ActivitySummaryEntries.GROUP_HEART_RATE, new LineData(dataset), integerFormatter, getUnitString(context, UNIT_BPM)));
}
if (hasSpeedValues && !speedDataPoints.isEmpty()) {
if (ActivityKind.isPaceActivity(activityKind)) {
final String label = String.format("%s (%s)", context.getString(R.string.Pace), getUnitString(context, UNIT_SECONDS_PER_KM));
final String label = String.format("%s (%s)", context.getString(R.string.Pace), getUnitString(context, UNIT_MINUTES_PER_KM));
final LineDataSet dataset = createDataSet(context, speedDataPoints, label, context.getResources().getColor(R.color.chart_line_speed));
charts.add(new WorkoutChart(context.getString(R.string.Pace), ActivitySummaryEntries.GROUP_SPEED, new LineData(dataset), new SpeedYLabelFormatter(UNIT_SECONDS_PER_KM)));
charts.add(new WorkoutChart("pace", context.getString(R.string.Pace), ActivitySummaryEntries.GROUP_SPEED, new LineData(dataset), new SpeedYLabelFormatter(UNIT_MINUTES_PER_KM), getUnitString(context, UNIT_MINUTES_PER_KM)));
} else {
final String label = String.format("%s (%s)", context.getString(R.string.Speed), getUnitString(context, UNIT_METERS_PER_SECOND));
final LineDataSet dataset = createDataSet(context, speedDataPoints, label, context.getResources().getColor(R.color.chart_line_speed));
charts.add(new WorkoutChart(context.getString(R.string.Speed), ActivitySummaryEntries.GROUP_SPEED, new LineData(dataset), new SpeedYLabelFormatter(UNIT_METERS_PER_SECOND)));
charts.add(new WorkoutChart("speed", context.getString(R.string.Speed), ActivitySummaryEntries.GROUP_SPEED, new LineData(dataset), new SpeedYLabelFormatter(UNIT_METERS_PER_SECOND), getUnitString(context, UNIT_METERS_PER_SECOND)));
}
}
if (!elevationDataPoints.isEmpty()) {
final String label = String.format("%s (%s)", context.getString(R.string.Elevation), getUnitString(context, UNIT_METERS));
LineDataSet dataset = createDataSet(context, elevationDataPoints, label, context.getResources().getColor(R.color.chart_line_elevation));
charts.add(new WorkoutChart(context.getString(R.string.Elevation), ActivitySummaryEntries.GROUP_ELEVATION, new LineData(dataset)));
charts.add(new WorkoutChart("elevation", context.getString(R.string.Elevation), ActivitySummaryEntries.GROUP_ELEVATION, new LineData(dataset), null, getUnitString(context, UNIT_METERS)));
}
return charts;
@@ -0,0 +1,137 @@
package nodomain.freeyourgadget.gadgetbridge.activities.workouts.charts
import android.os.Bundle
import android.widget.Toast
import com.github.mikephil.charting.components.XAxis
import com.github.mikephil.charting.components.YAxis
import com.github.mikephil.charting.data.LineData
import com.github.mikephil.charting.data.LineDataSet
import com.github.mikephil.charting.formatter.DefaultAxisValueFormatter
import com.github.mikephil.charting.formatter.ValueFormatter
import com.github.mikephil.charting.interfaces.datasets.ILineDataSet
import com.google.android.material.chip.Chip
import nodomain.freeyourgadget.gadgetbridge.GBApplication
import nodomain.freeyourgadget.gadgetbridge.R
import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity
import nodomain.freeyourgadget.gadgetbridge.activities.charts.DurationXLabelFormatter
import nodomain.freeyourgadget.gadgetbridge.activities.charts.marker.ValueMarker
import nodomain.freeyourgadget.gadgetbridge.databinding.WorkoutChartsBinding
import nodomain.freeyourgadget.gadgetbridge.model.workout.WorkoutChart
import kotlin.math.max
class WorkoutChartsActivity : AbstractGBActivity() {
private lateinit var binding: WorkoutChartsBinding
private var chartData: List<WorkoutChart>? = null
val selectedCharts = mutableListOf<Any>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = WorkoutChartsBinding.inflate(layoutInflater)
setContentView(binding.root)
chartData = ChartDataRepository.chartData
if (chartData == null) {
Toast.makeText(this, "No charts data found", Toast.LENGTH_SHORT).show()
finish()
return
}
ChartDataRepository.clear()
val chartTextColor = GBApplication.getSecondaryTextColor(baseContext);
binding.workoutDataChart.xAxis.apply {
setDrawLabels(true)
setDrawGridLines(false)
setDrawLimitLinesBehindData(true)
isEnabled = true
textColor = chartTextColor
position = XAxis.XAxisPosition.BOTTOM
valueFormatter = DurationXLabelFormatter("mm:ss")
}
binding.workoutDataChart.axisLeft.apply {
setDrawGridLines(false)
setDrawTopYLabelEntry(true)
textColor = chartTextColor
isEnabled = true
}
binding.workoutDataChart.axisRight.apply {
setDrawGridLines(false)
setDrawTopYLabelEntry(true)
textColor = chartTextColor
isEnabled = true
}
binding.workoutDataChart.description.isEnabled = false;
binding.workoutDataChart.legend.textColor = GBApplication.getTextColor(baseContext);
val initChartId = intent.getStringExtra(INIT_CHART_ID) ?: "none"
selectedCharts.add(0, initChartId);
setupChipGroup(initChartId);
refreshChart()
}
fun setupChipGroup(initChartId: String) {
for (chart in chartData!!) {
val chip = Chip(this).apply {
text = chart.title
isCheckable = true
isClickable = true
tag = chart.id
isChecked = chart.id.equals(initChartId)
}
chip.setOnCheckedChangeListener { _, isChecked ->
val tag = chip.tag
if (isChecked) {
if (selectedCharts.size >= 2) {
chip.isChecked = false
Toast.makeText(this, "You can only compare two items at a time.", Toast.LENGTH_SHORT).show()
} else {
selectedCharts.add(tag)
refreshChart()
}
} else {
if (selectedCharts.size == 1 && selectedCharts.contains(tag)) {
chip.isChecked = true
Toast.makeText(this, "There should be at least one item selected.", Toast.LENGTH_SHORT).show()
} else {
selectedCharts.remove(tag)
refreshChart()
}
}
}
binding.workoutDataChartChipGroup.addView(chip)
}
}
fun refreshChart() {
val lineDataSets = mutableListOf<ILineDataSet>()
val lineDataSetsMarkerFormatters = mutableListOf<ValueFormatter?>()
val lineDataSetsMarkerUnits = mutableListOf<String?>()
var leftY = true
selectedCharts.forEach { selectedChart ->
val workoutChart = chartData?.find { it.id == selectedChart } ?: return@forEach
val dataSet = workoutChart.chartData.getDataSetByIndex(0) as? LineDataSet ?: return@forEach
dataSet.highLightColor = this.getColor(R.color.chart_highline_dolor)
dataSet.highlightLineWidth = 1f;
dataSet.axisDependency = if(leftY) YAxis.AxisDependency.LEFT else YAxis.AxisDependency.RIGHT;
lineDataSets.add(dataSet)
lineDataSetsMarkerFormatters.add(workoutChart.chartYLabelFormatter);
lineDataSetsMarkerUnits.add(workoutChart.unitString);
val axis = if (leftY) binding.workoutDataChart.axisLeft else binding.workoutDataChart.axisRight
axis.valueFormatter = workoutChart.chartYLabelFormatter ?: DefaultAxisValueFormatter(0)
leftY = false
}
if (selectedCharts.size == 1) {
val selectedChartId = selectedCharts.first()
val workoutChart = chartData?.find { it.id == selectedChartId } ?: return
binding.workoutDataChart.axisRight.valueFormatter = workoutChart?.chartYLabelFormatter ?: DefaultAxisValueFormatter(0)
}
val lineData = LineData(lineDataSets)
binding.workoutDataChart.data = lineData;
binding.workoutDataChart.marker = ValueMarker(this, lineData, lineDataSetsMarkerFormatters, lineDataSetsMarkerUnits);
binding.workoutDataChart.highlightValues(null)
binding.workoutDataChart.invalidate()
}
companion object {
const val INIT_CHART_ID = "INIT_CHART_ID"
}
}
@@ -195,6 +195,7 @@ public class ActivitySummaryEntries {
public static final String UNIT_SECONDS = "seconds";
public static final String UNIT_MILLISECONDS = "milliseconds_ms";
public static final String UNIT_SECONDS_PER_KM = "seconds_km";
public static final String UNIT_MINUTES_PER_KM = "minutes_km";
public static final String UNIT_SECONDS_PER_M = "seconds_m";
public static final String UNIT_METERS_PER_SECOND = "meters_second";
public static final String UNIT_KMPH = "km_h";
@@ -4,8 +4,10 @@ import com.github.mikephil.charting.data.ChartData
import com.github.mikephil.charting.formatter.ValueFormatter
data class WorkoutChart @JvmOverloads constructor(
val id: String,
val title: String,
val group: String,
val chartData: ChartData<*>,
var chartYLabelFormatter: ValueFormatter? = null,
var unitString: String? = null,
)
@@ -0,0 +1,7 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="?android:windowBackground" />
<stroke
android:width="1dp"
android:color="@android:color/holo_orange_dark" />
</shape>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/marker_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="14sp"
android:textColor="?attr/textColorPrimary"
android:background="@drawable/marker_value_background"
android:paddingTop="5dp"
android:paddingBottom="5dp"
android:paddingLeft="5dp"
android:paddingRight="5dp"
/>
</LinearLayout>
@@ -0,0 +1,27 @@
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:background="?android:attr/windowBackground"
android:orientation="vertical"
android:layout_height="match_parent"
tools:context="nodomain.freeyourgadget.gadgetbridge.activities.workouts.charts.WorkoutChartsActivity">
<com.github.mikephil.charting.charts.LineChart
android:id="@+id/workout_data_chart"
android:layout_width="fill_parent"
android:layout_height="300dp"
android:layout_alignParentTop="true"
/>
<com.google.android.material.chip.ChipGroup
android:id="@+id/workout_data_chart_chip_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/workout_data_chart"
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp"
android:layout_marginTop="8dp"
/>
</RelativeLayout>
+1
View File
@@ -92,6 +92,7 @@
<color name="chart_line_speed" type="color">#80D8FF</color>
<color name="chart_line_elevation" type="color">#66BB6A</color>
<color name="chart_line_heart_rate" type="color">#F44336</color>
<color name="chart_highline_dolor" type="color">#ffff8800</color>
<attr name="row_separator" format="color" />
<color name="alternate_row_background_light">#FFEDEDED</color>
+1
View File
@@ -4151,6 +4151,7 @@
<string name="huawei_better_audio_quality_title">Prioritise better audio quality.</string>
<string name="huawei_better_audio_quality_summary">Enable better audio codecs (LDAC). This should improve sound quality, but it increases power consumption and can cause delays.</string>
<string name="maps">Maps</string>
<string name="workout_charts">Workout Charts</string>
<string name="maps_settings">Maps settings</string>
<string name="maps_download_title">Download Maps</string>
<string name="maps_download_summary">Click here to download map files. They should be saved to the folder selected in the preference below.</string>