Skip to content

Commit

Permalink
Merge pull request #634 from wger-project/chart-weight-since-plan
Browse files Browse the repository at this point in the history
better weight and measurements visualisation
  • Loading branch information
Dieterbe committed Sep 18, 2024
2 parents beb926d + 349efa6 commit dfd18d1
Show file tree
Hide file tree
Showing 10 changed files with 305 additions and 96 deletions.
6 changes: 4 additions & 2 deletions lib/screens/measurement_entries_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,10 @@ class MeasurementEntriesScreen extends StatelessWidget {
);
},
),
body: Consumer<MeasurementProvider>(
builder: (context, provider, child) => EntriesList(category),
body: SingleChildScrollView(
child: Consumer<MeasurementProvider>(
builder: (context, provider, child) => EntriesList(category),
),
),
);
}
Expand Down
8 changes: 5 additions & 3 deletions lib/screens/weight_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ import 'package:provider/provider.dart';
import 'package:wger/providers/body_weight.dart';
import 'package:wger/screens/form_screen.dart';
import 'package:wger/widgets/core/app_bar.dart';
import 'package:wger/widgets/weight/entries_list.dart';
import 'package:wger/widgets/weight/forms.dart';
import 'package:wger/widgets/weight/weight_overview.dart';

class WeightScreen extends StatelessWidget {
const WeightScreen();
Expand All @@ -48,8 +48,10 @@ class WeightScreen extends StatelessWidget {
);
},
),
body: Consumer<BodyWeightProvider>(
builder: (context, workoutProvider, child) => const WeightEntriesList(),
body: SingleChildScrollView(
child: Consumer<BodyWeightProvider>(
builder: (context, workoutProvider, child) => const WeightOverview(),
),
),
);
}
Expand Down
21 changes: 15 additions & 6 deletions lib/widgets/dashboard/widgets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ class _DashboardWeightWidgetState extends State<DashboardWeightWidget> {
final profile = context.read<UserProvider>().profile;
final weightProvider = context.read<BodyWeightProvider>();

final entriesAll =
weightProvider.items.map((e) => MeasurementChartEntry(e.weight, e.date)).toList();
final entries7dAvg = moving7dAverage(entriesAll);

return Consumer<BodyWeightProvider>(
builder: (context, workoutProvider, child) => Card(
child: Column(
Expand All @@ -182,14 +186,16 @@ class _DashboardWeightWidgetState extends State<DashboardWeightWidget> {
SizedBox(
height: 200,
child: MeasurementChartWidgetFl(
weightProvider.items
.map((e) => MeasurementChartEntry(e.weight, e.date))
.toList(),
unit: profile!.isMetric
? AppLocalizations.of(context).kg
: AppLocalizations.of(context).lb,
entriesAll,
weightUnit(profile!.isMetric, context),
avgs: entries7dAvg,
),
),
MeasurementOverallChangeWidget(
entries7dAvg.first,
entries7dAvg.last,
weightUnit(profile!.isMetric, context),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expand Down Expand Up @@ -274,6 +280,9 @@ class _DashboardMeasurementWidgetState extends State<DashboardMeasurementWidget>
FontAwesomeIcons.chartLine,
color: Theme.of(context).textTheme.headlineSmall!.color,
),
// TODO: this icon feels out of place and inconsistent with all
// other dashboard widgets.
// maybe we should just add a "Go to all" at the bottom of the widget
trailing: IconButton(
icon: const Icon(Icons.arrow_forward),
onPressed: () => Navigator.pushNamed(
Expand Down
15 changes: 12 additions & 3 deletions lib/widgets/measurements/categories_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@ class CategoriesCard extends StatelessWidget {

@override
Widget build(BuildContext context) {
final entriesAll =
currentCategory.entries.map((e) => MeasurementChartEntry(e.value, e.date)).toList();
final entries7dAvg = moving7dAverage(entriesAll);

return Card(
elevation: elevation,
color: Theme.of(context).colorScheme.onInverseSurface,
child: Column(
children: [
Padding(
Expand All @@ -31,10 +34,16 @@ class CategoriesCard extends StatelessWidget {
padding: const EdgeInsets.all(10),
height: 220,
child: MeasurementChartWidgetFl(
currentCategory.entries.map((e) => MeasurementChartEntry(e.value, e.date)).toList(),
unit: currentCategory.unit,
entriesAll,
currentCategory.unit,
avgs: entries7dAvg,
),
),
MeasurementOverallChangeWidget(
entries7dAvg.first,
entries7dAvg.last,
currentCategory.unit,
),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
Expand Down
130 changes: 103 additions & 27 deletions lib/widgets/measurements/charts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,39 @@

import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:intl/intl.dart';
import 'package:wger/helpers/charts.dart';

class MeasurementOverallChangeWidget extends StatelessWidget {
final MeasurementChartEntry _first;
final MeasurementChartEntry _last;
final String _unit;
const MeasurementOverallChangeWidget(this._first, this._last, this._unit);

@override
Widget build(BuildContext context) {
final delta = _last.value - _first.value;
final prefix = delta > 0
? '+'
: delta < 0
? '-'
: '';

return Text('overall change $prefix ${delta.abs().toStringAsFixed(1)} $_unit');
}
}

String weightUnit(bool isMetric, BuildContext context) {
return isMetric ? AppLocalizations.of(context).kg : AppLocalizations.of(context).lb;
}

class MeasurementChartWidgetFl extends StatefulWidget {
final List<MeasurementChartEntry> _entries;
final String unit;
final List<MeasurementChartEntry>? avgs;
final String _unit;

const MeasurementChartWidgetFl(this._entries, {this.unit = 'kg'});
const MeasurementChartWidgetFl(this._entries, this._unit, {this.avgs});

@override
State<MeasurementChartWidgetFl> createState() => _MeasurementChartWidgetFlState();
Expand All @@ -37,12 +62,7 @@ class _MeasurementChartWidgetFlState extends State<MeasurementChartWidgetFl> {
return AspectRatio(
aspectRatio: 1.70,
child: Padding(
padding: const EdgeInsets.only(
right: 18,
left: 12,
top: 24,
bottom: 12,
),
padding: const EdgeInsets.all(4),
child: LineChart(mainData()),
),
);
Expand All @@ -53,8 +73,8 @@ class _MeasurementChartWidgetFlState extends State<MeasurementChartWidgetFl> {
touchTooltipData: LineTouchTooltipData(getTooltipItems: (touchedSpots) {
return touchedSpots.map((touchedSpot) {
return LineTooltipItem(
'${touchedSpot.y} kg',
const TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
'${touchedSpot.y.toStringAsFixed(1)} ${widget._unit}',
TextStyle(color: touchedSpot.bar.color, fontWeight: FontWeight.bold),
);
}).toList();
}),
Expand All @@ -67,13 +87,13 @@ class _MeasurementChartWidgetFlState extends State<MeasurementChartWidgetFl> {
gridData: FlGridData(
show: true,
drawVerticalLine: true,
//horizontalInterval: 1,
//verticalInterval: interval,
// horizontalInterval: 1,
// verticalInterval: 1,
getDrawingHorizontalLine: (value) {
return const FlLine(color: Colors.grey, strokeWidth: 1);
return FlLine(color: Theme.of(context).colorScheme.primaryContainer, strokeWidth: 1);
},
getDrawingVerticalLine: (value) {
return const FlLine(color: Colors.grey, strokeWidth: 1);
return FlLine(color: Theme.of(context).colorScheme.primaryContainer, strokeWidth: 1);
},
),
titlesData: FlTitlesData(
Expand All @@ -88,14 +108,22 @@ class _MeasurementChartWidgetFlState extends State<MeasurementChartWidgetFl> {
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
// Don't show the first and last entries, otherwise they'll overlap with the
// calculated interval
// Don't show the first and last entries, to avoid overlap
// see https://stackoverflow.com/questions/73355777/flutter-fl-chart-how-can-we-avoid-the-overlap-of-the-ordinate
// this is needlessly aggressive if the titles are "sparse", but we should optimize for more busy data
if (value == meta.min || value == meta.max) {
return const Text('');
}
final DateTime date = DateTime.fromMillisecondsSinceEpoch(value.toInt());
// if we go across years, show years in the ticks. otherwise leave them out
if (DateTime.fromMillisecondsSinceEpoch(meta.min.toInt()).year !=
DateTime.fromMillisecondsSinceEpoch(meta.max.toInt()).year) {
return Text(
DateFormat.yMd(Localizations.localeOf(context).languageCode).format(date),
);
}
return Text(
DateFormat.yMd(Localizations.localeOf(context).languageCode).format(date),
DateFormat.Md(Localizations.localeOf(context).languageCode).format(date),
);
},
interval: widget._entries.isNotEmpty
Expand All @@ -111,29 +139,49 @@ class _MeasurementChartWidgetFlState extends State<MeasurementChartWidgetFl> {
showTitles: true,
reservedSize: 65,
getTitlesWidget: (value, meta) {
return Text('$value ${widget.unit}');
// Don't show the first and last entries, to avoid overlap
// see https://stackoverflow.com/questions/73355777/flutter-fl-chart-how-can-we-avoid-the-overlap-of-the-ordinate
// this is needlessly aggressive if the titles are "sparse", but we should optimize for more busy data
if (value == meta.min || value == meta.max) {
return const Text('');
}

return Text('$value ${widget._unit}');
},
),
),
),
borderData: FlBorderData(
show: true,
border: Border.all(color: const Color(0xff37434d)),
border: Border.all(color: Theme.of(context).colorScheme.primaryContainer),
),
lineBarsData: [
LineChartBarData(
spots: [
...widget._entries.map((e) => FlSpot(
e.date.millisecondsSinceEpoch.toDouble(),
e.value.toDouble(),
)),
],
spots: widget._entries
.map((e) => FlSpot(
e.date.millisecondsSinceEpoch.toDouble(),
e.value.toDouble(),
))
.toList(),
isCurved: false,
color: Theme.of(context).colorScheme.secondary,
barWidth: 2,
color: Theme.of(context).colorScheme.primary,
barWidth: 0,
isStrokeCapRound: true,
dotData: const FlDotData(show: true),
),
if (widget.avgs != null)
LineChartBarData(
spots: widget.avgs!
.map((e) => FlSpot(
e.date.millisecondsSinceEpoch.toDouble(),
e.value.toDouble(),
))
.toList(),
isCurved: false,
color: Theme.of(context).colorScheme.tertiary,
barWidth: 1,
dotData: const FlDotData(show: false),
),
],
);
}
Expand All @@ -146,6 +194,34 @@ class MeasurementChartEntry {
MeasurementChartEntry(this.value, this.date);
}

// for each point, return the average of all the points in the 7 days preceeding it
List<MeasurementChartEntry> moving7dAverage(List<MeasurementChartEntry> vals) {
var start = 0;
var end = 0;
final List<MeasurementChartEntry> out = <MeasurementChartEntry>[];

// first make sure our list is in ascending order
vals.sort((a, b) => a.date.compareTo(b.date));

while (end < vals.length) {
// since users can log measurements several days, or minutes apart,
// we can't make assumptions. We have to manually advance 'start'
// such that it is always the first point within our desired range.
// posibly start == end (when there is only one point in the range)
final intervalStart = vals[end].date.subtract(const Duration(days: 7));
while (start < end && vals[start].date.isBefore(intervalStart)) {
start++;
}

final sub = vals.sublist(start, end + 1).map((e) => e.value);
final sum = sub.reduce((val, el) => val + el);
out.add(MeasurementChartEntry(sum / sub.length, vals[end].date));

end++;
}
return out;
}

class Indicator extends StatelessWidget {
const Indicator({
super.key,
Expand Down
25 changes: 17 additions & 8 deletions lib/widgets/measurements/entries.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:wger/models/measurements/measurement_category.dart';
import 'package:wger/providers/measurement.dart';
import 'package:wger/providers/nutrition.dart';
import 'package:wger/screens/form_screen.dart';
import 'package:wger/widgets/measurements/charts.dart';
import 'package:wger/widgets/measurements/helpers.dart';

import 'forms.dart';

Expand All @@ -34,16 +36,23 @@ class EntriesList extends StatelessWidget {

@override
Widget build(BuildContext context) {
final plan = Provider.of<NutritionPlansProvider>(context, listen: false).currentPlan;

final entriesAll =
_category.entries.map((e) => MeasurementChartEntry(e.value, e.date)).toList();
final entries7dAvg = moving7dAverage(entriesAll);

return Column(children: [
Container(
padding: const EdgeInsets.all(10),
height: 220,
child: MeasurementChartWidgetFl(
_category.entries.map((e) => MeasurementChartEntry(e.value, e.date)).toList(),
unit: _category.unit,
),
...getOverviewWidgetsSeries(
_category.name,
entriesAll,
entries7dAvg,
plan,
_category.unit,
context,
),
Expanded(
SizedBox(
height: 300,
child: ListView.builder(
padding: const EdgeInsets.all(10.0),
itemCount: _category.entries.length,
Expand Down
Loading

0 comments on commit dfd18d1

Please sign in to comment.