Skip to content

Commit f9b9885

Browse files
Bhavit16claude
andcommitted
feat(weight): add time-range selector to body weight chart
The body weight chart always plotted the entire history, which becomes hard to read for users with a lot of entries going back a long time. Add a segmented selector above the chart with "All" / "Last year" / "Last 3 months" options (the approach suggested in the issue) that restricts the plotted range. "All" is the default, so the existing behaviour is unchanged unless the user opts in. The range filtering is applied to the weight overview only; the shared getOverviewWidgetsSeries helper gains an optional mainChartTitle so the header reflects the selected range, leaving the measurements call site untouched. Closes #148 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 65dbf42 commit f9b9885

4 files changed

Lines changed: 126 additions & 8 deletions

File tree

lib/l10n/app_en.arb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,38 @@
534534
}
535535
}
536536
},
537+
"chartLastYearTitle": "{name} last year",
538+
"@chartLastYearTitle": {
539+
"description": "last year chart of 'name' (e.g. 'weight', 'body fat' etc.)",
540+
"type": "text",
541+
"placeholders": {
542+
"name": {
543+
"type": "String"
544+
}
545+
}
546+
},
547+
"chartLast3MonthsTitle": "{name} last 3 months",
548+
"@chartLast3MonthsTitle": {
549+
"description": "last 3 months chart of 'name' (e.g. 'weight', 'body fat' etc.)",
550+
"type": "text",
551+
"placeholders": {
552+
"name": {
553+
"type": "String"
554+
}
555+
}
556+
},
557+
"chartRangeAll": "All",
558+
"@chartRangeAll": {
559+
"description": "Label for the chart time-range selector option showing all data"
560+
},
561+
"chartRangeLastYear": "Last year",
562+
"@chartRangeLastYear": {
563+
"description": "Label for the chart time-range selector option showing the last year"
564+
},
565+
"chartRangeLast3Months": "Last 3 months",
566+
"@chartRangeLast3Months": {
567+
"description": "Label for the chart time-range selector option showing the last 3 months"
568+
},
537569
"measurement": "Measurement",
538570
"@measurement": {},
539571
"measurements": "Measurements",

lib/widgets/measurements/helpers.dart

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,13 @@ List<Widget> getOverviewWidgetsSeries(
4242
List<MeasurementChartEntry> entries7dAvg,
4343
List<NutritionalPlan> plans,
4444
String unit,
45-
BuildContext context,
46-
) {
45+
BuildContext context, {
46+
String? mainChartTitle,
47+
}) {
4748
final monthAgo = DateTime.now().subtract(const Duration(days: 30));
4849
return [
4950
...getOverviewWidgets(
50-
AppLocalizations.of(context).chartAllTimeTitle(name),
51+
mainChartTitle ?? AppLocalizations.of(context).chartAllTimeTitle(name),
5152
entriesAll,
5253
entries7dAvg,
5354
unit,

lib/widgets/weight/weight_overview.dart

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import 'package:flutter/material.dart';
2020
import 'package:flutter_riverpod/flutter_riverpod.dart' as riverpod;
2121
import 'package:intl/intl.dart';
22+
import 'package:wger/helpers/measurements.dart';
2223
import 'package:wger/l10n/generated/app_localizations.dart';
2324
import 'package:wger/models/body_weight/weight_entry.dart';
2425
import 'package:wger/providers/body_weight_notifier.dart';
@@ -31,11 +32,22 @@ import 'package:wger/widgets/measurements/charts.dart';
3132
import 'package:wger/widgets/measurements/helpers.dart';
3233
import 'package:wger/widgets/weight/forms.dart';
3334

34-
class WeightOverview extends riverpod.ConsumerWidget {
35+
/// Time range the user can pick to limit how far back the weight chart goes.
36+
enum WeightChartRange { all, lastYear, last3Months }
37+
38+
class WeightOverview extends riverpod.ConsumerStatefulWidget {
3539
const WeightOverview();
3640

3741
@override
38-
Widget build(BuildContext context, riverpod.WidgetRef ref) {
42+
riverpod.ConsumerState<WeightOverview> createState() => _WeightOverviewState();
43+
}
44+
45+
class _WeightOverviewState extends riverpod.ConsumerState<WeightOverview> {
46+
WeightChartRange _range = WeightChartRange.all;
47+
48+
@override
49+
Widget build(BuildContext context) {
50+
final i18n = AppLocalizations.of(context);
3951
final profileAsync = ref.watch(userProfileProvider);
4052
final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString());
4153
final plans = ref.watch(nutritionProvider).value?.plans ?? const [];
@@ -54,17 +66,63 @@ class WeightOverview extends riverpod.ConsumerWidget {
5466
final entriesAll = entriesList.map((e) => MeasurementChartEntry(e.weight, e.date)).toList();
5567
final entries7dAvg = moving7dAverage(entriesAll);
5668

69+
// Restrict the data to the selected range. The average is computed over
70+
// the full history first and only filtered afterwards, matching how the
71+
// per-plan and 30-day sub-charts are built.
72+
final (DateTime? cutoff, String? mainChartTitle) = switch (_range) {
73+
WeightChartRange.all => (null, null),
74+
WeightChartRange.lastYear => (
75+
DateTime.now().subtract(const Duration(days: 365)),
76+
i18n.chartLastYearTitle(i18n.weight),
77+
),
78+
WeightChartRange.last3Months => (
79+
DateTime.now().subtract(const Duration(days: 90)),
80+
i18n.chartLast3MonthsTitle(i18n.weight),
81+
),
82+
};
83+
final entriesRange = cutoff == null ? entriesAll : entriesAll.whereDate(cutoff, null);
84+
final entries7dAvgRange =
85+
cutoff == null ? entries7dAvg : entries7dAvg.whereDate(cutoff, null);
86+
5787
final unit = weightUnit(profile.isMetric, context);
5888

5989
return Column(
6090
children: [
91+
Padding(
92+
padding: const EdgeInsets.symmetric(horizontal: 15),
93+
child: SingleChildScrollView(
94+
scrollDirection: Axis.horizontal,
95+
child: SegmentedButton<WeightChartRange>(
96+
key: const ValueKey('weightChartRangeSelector'),
97+
showSelectedIcon: false,
98+
segments: [
99+
ButtonSegment(
100+
value: WeightChartRange.all,
101+
label: Text(i18n.chartRangeAll),
102+
),
103+
ButtonSegment(
104+
value: WeightChartRange.lastYear,
105+
label: Text(i18n.chartRangeLastYear),
106+
),
107+
ButtonSegment(
108+
value: WeightChartRange.last3Months,
109+
label: Text(i18n.chartRangeLast3Months),
110+
),
111+
],
112+
selected: {_range},
113+
onSelectionChanged: (selection) =>
114+
setState(() => _range = selection.first),
115+
),
116+
),
117+
),
61118
...getOverviewWidgetsSeries(
62-
AppLocalizations.of(context).weight,
63-
entriesAll,
64-
entries7dAvg,
119+
i18n.weight,
120+
entriesRange,
121+
entries7dAvgRange,
65122
plans,
66123
unit,
67124
context,
125+
mainChartTitle: mainChartTitle,
68126
),
69127
TextButton(
70128
onPressed: () => Navigator.pushNamed(

test/weight/weight_screen_test.dart

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,33 @@ void main() {
9494
expect(find.byType(ListTile), findsNWidgets(2));
9595
});
9696

97+
testWidgets('Weight chart range selector filters the data', (WidgetTester tester) async {
98+
await tester.pumpWidget(createWeightScreen());
99+
await tester.pumpAndSettle();
100+
101+
// The range selector is shown with the three options
102+
expect(find.byKey(const ValueKey('weightChartRangeSelector')), findsOneWidget);
103+
expect(find.text('All'), findsOneWidget);
104+
expect(find.text('Last year'), findsOneWidget);
105+
expect(find.text('Last 3 months'), findsOneWidget);
106+
107+
// By default (all time) the chart is shown for the seeded entries
108+
expect(find.byType(MeasurementChartWidgetFl), findsOneWidget);
109+
110+
// Restricting to the last 3 months excludes the (old) seeded data
111+
await tester.ensureVisible(find.text('Last 3 months'));
112+
await tester.tap(find.text('Last 3 months'));
113+
await tester.pumpAndSettle();
114+
expect(find.byType(MeasurementChartWidgetFl), findsNothing);
115+
expect(find.text('No data available'), findsOneWidget);
116+
117+
// Switching back to all time restores the chart
118+
await tester.ensureVisible(find.text('All'));
119+
await tester.tap(find.text('All'));
120+
await tester.pumpAndSettle();
121+
expect(find.byType(MeasurementChartWidgetFl), findsOneWidget);
122+
});
123+
97124
testWidgets('Test deleting an item using the Delete button', (WidgetTester tester) async {
98125
// Arrange
99126
await tester.pumpWidget(createWeightScreen());

0 commit comments

Comments
 (0)