From f9b9885de75756f483a66598a9b98c821d70b539 Mon Sep 17 00:00:00 2001 From: bhavit04 Date: Wed, 24 Jun 2026 16:45:41 +0530 Subject: [PATCH] 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 --- lib/l10n/app_en.arb | 32 ++++++++++++ lib/widgets/measurements/helpers.dart | 7 +-- lib/widgets/weight/weight_overview.dart | 68 +++++++++++++++++++++++-- test/weight/weight_screen_test.dart | 27 ++++++++++ 4 files changed, 126 insertions(+), 8 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b185a7107..47d955f6f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -534,6 +534,38 @@ } } }, + "chartLastYearTitle": "{name} last year", + "@chartLastYearTitle": { + "description": "last year chart of 'name' (e.g. 'weight', 'body fat' etc.)", + "type": "text", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "chartLast3MonthsTitle": "{name} last 3 months", + "@chartLast3MonthsTitle": { + "description": "last 3 months chart of 'name' (e.g. 'weight', 'body fat' etc.)", + "type": "text", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "chartRangeAll": "All", + "@chartRangeAll": { + "description": "Label for the chart time-range selector option showing all data" + }, + "chartRangeLastYear": "Last year", + "@chartRangeLastYear": { + "description": "Label for the chart time-range selector option showing the last year" + }, + "chartRangeLast3Months": "Last 3 months", + "@chartRangeLast3Months": { + "description": "Label for the chart time-range selector option showing the last 3 months" + }, "measurement": "Measurement", "@measurement": {}, "measurements": "Measurements", diff --git a/lib/widgets/measurements/helpers.dart b/lib/widgets/measurements/helpers.dart index 00369b06d..67c75f891 100644 --- a/lib/widgets/measurements/helpers.dart +++ b/lib/widgets/measurements/helpers.dart @@ -42,12 +42,13 @@ List getOverviewWidgetsSeries( List entries7dAvg, List plans, String unit, - BuildContext context, -) { + BuildContext context, { + String? mainChartTitle, +}) { final monthAgo = DateTime.now().subtract(const Duration(days: 30)); return [ ...getOverviewWidgets( - AppLocalizations.of(context).chartAllTimeTitle(name), + mainChartTitle ?? AppLocalizations.of(context).chartAllTimeTitle(name), entriesAll, entries7dAvg, unit, diff --git a/lib/widgets/weight/weight_overview.dart b/lib/widgets/weight/weight_overview.dart index 53ef5db4a..0533860f5 100644 --- a/lib/widgets/weight/weight_overview.dart +++ b/lib/widgets/weight/weight_overview.dart @@ -19,6 +19,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart' as riverpod; import 'package:intl/intl.dart'; +import 'package:wger/helpers/measurements.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/body_weight/weight_entry.dart'; import 'package:wger/providers/body_weight_notifier.dart'; @@ -31,11 +32,22 @@ import 'package:wger/widgets/measurements/charts.dart'; import 'package:wger/widgets/measurements/helpers.dart'; import 'package:wger/widgets/weight/forms.dart'; -class WeightOverview extends riverpod.ConsumerWidget { +/// Time range the user can pick to limit how far back the weight chart goes. +enum WeightChartRange { all, lastYear, last3Months } + +class WeightOverview extends riverpod.ConsumerStatefulWidget { const WeightOverview(); @override - Widget build(BuildContext context, riverpod.WidgetRef ref) { + riverpod.ConsumerState createState() => _WeightOverviewState(); +} + +class _WeightOverviewState extends riverpod.ConsumerState { + WeightChartRange _range = WeightChartRange.all; + + @override + Widget build(BuildContext context) { + final i18n = AppLocalizations.of(context); final profileAsync = ref.watch(userProfileProvider); final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString()); final plans = ref.watch(nutritionProvider).value?.plans ?? const []; @@ -54,17 +66,63 @@ class WeightOverview extends riverpod.ConsumerWidget { final entriesAll = entriesList.map((e) => MeasurementChartEntry(e.weight, e.date)).toList(); final entries7dAvg = moving7dAverage(entriesAll); + // Restrict the data to the selected range. The average is computed over + // the full history first and only filtered afterwards, matching how the + // per-plan and 30-day sub-charts are built. + final (DateTime? cutoff, String? mainChartTitle) = switch (_range) { + WeightChartRange.all => (null, null), + WeightChartRange.lastYear => ( + DateTime.now().subtract(const Duration(days: 365)), + i18n.chartLastYearTitle(i18n.weight), + ), + WeightChartRange.last3Months => ( + DateTime.now().subtract(const Duration(days: 90)), + i18n.chartLast3MonthsTitle(i18n.weight), + ), + }; + final entriesRange = cutoff == null ? entriesAll : entriesAll.whereDate(cutoff, null); + final entries7dAvgRange = + cutoff == null ? entries7dAvg : entries7dAvg.whereDate(cutoff, null); + final unit = weightUnit(profile.isMetric, context); return Column( children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SegmentedButton( + key: const ValueKey('weightChartRangeSelector'), + showSelectedIcon: false, + segments: [ + ButtonSegment( + value: WeightChartRange.all, + label: Text(i18n.chartRangeAll), + ), + ButtonSegment( + value: WeightChartRange.lastYear, + label: Text(i18n.chartRangeLastYear), + ), + ButtonSegment( + value: WeightChartRange.last3Months, + label: Text(i18n.chartRangeLast3Months), + ), + ], + selected: {_range}, + onSelectionChanged: (selection) => + setState(() => _range = selection.first), + ), + ), + ), ...getOverviewWidgetsSeries( - AppLocalizations.of(context).weight, - entriesAll, - entries7dAvg, + i18n.weight, + entriesRange, + entries7dAvgRange, plans, unit, context, + mainChartTitle: mainChartTitle, ), TextButton( onPressed: () => Navigator.pushNamed( diff --git a/test/weight/weight_screen_test.dart b/test/weight/weight_screen_test.dart index d6391e31e..22e15953d 100644 --- a/test/weight/weight_screen_test.dart +++ b/test/weight/weight_screen_test.dart @@ -94,6 +94,33 @@ void main() { expect(find.byType(ListTile), findsNWidgets(2)); }); + testWidgets('Weight chart range selector filters the data', (WidgetTester tester) async { + await tester.pumpWidget(createWeightScreen()); + await tester.pumpAndSettle(); + + // The range selector is shown with the three options + expect(find.byKey(const ValueKey('weightChartRangeSelector')), findsOneWidget); + expect(find.text('All'), findsOneWidget); + expect(find.text('Last year'), findsOneWidget); + expect(find.text('Last 3 months'), findsOneWidget); + + // By default (all time) the chart is shown for the seeded entries + expect(find.byType(MeasurementChartWidgetFl), findsOneWidget); + + // Restricting to the last 3 months excludes the (old) seeded data + await tester.ensureVisible(find.text('Last 3 months')); + await tester.tap(find.text('Last 3 months')); + await tester.pumpAndSettle(); + expect(find.byType(MeasurementChartWidgetFl), findsNothing); + expect(find.text('No data available'), findsOneWidget); + + // Switching back to all time restores the chart + await tester.ensureVisible(find.text('All')); + await tester.tap(find.text('All')); + await tester.pumpAndSettle(); + expect(find.byType(MeasurementChartWidgetFl), findsOneWidget); + }); + testWidgets('Test deleting an item using the Delete button', (WidgetTester tester) async { // Arrange await tester.pumpWidget(createWeightScreen());