diff --git a/lib/helpers/consts.dart b/lib/helpers/consts.dart index 7d538e02..e7136ca0 100644 --- a/lib/helpers/consts.dart +++ b/lib/helpers/consts.dart @@ -137,3 +137,7 @@ enum WeightUnitEnum { kg, lb } const textInputTypeDecimal = TextInputType.numberWithOptions(decimal: true); const String API_MAX_PAGE_SIZE = '999'; + +/// Marker used for identifying interpolated values in a list, e.g. for measurements +/// the milliseconds in the entry date are set to this value +const INTERPOLATION_MARKER = 123; diff --git a/lib/helpers/measurements.dart b/lib/helpers/measurements.dart new file mode 100644 index 00000000..f3cd5462 --- /dev/null +++ b/lib/helpers/measurements.dart @@ -0,0 +1,113 @@ +/* + * This file is part of wger Workout Manager . + * Copyright (C) 2020, 2021 wger Team + * + * wger Workout Manager is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:wger/helpers/consts.dart'; +import 'package:wger/helpers/date.dart'; +import 'package:wger/widgets/measurements/charts.dart'; + +extension MeasurementChartEntryListExtensions on List { + List whereDate(DateTime start, DateTime? end) { + return where((e) => e.date.isAfter(start) && (end == null || e.date.isBefore(end))).toList(); + } + +// assures values on the start (and optionally end) dates exist, by interpolating if needed +// this is used for when you are looking at a specific time frame (e.g. for a nutrition plan) +// while gaps in the middle of a chart can be "visually interpolated", it's good to have a clearer +// explicit interpolation for the start and end dates (if needed) +// this also helps with computing delta's across the entire window + List whereDateWithInterpolation(DateTime start, DateTime? end) { + // Make sure our list is sorted by date + sort((a, b) => a.date.compareTo(b.date)); + + // Initialize result list + final List result = []; + + // Check if we have any entries on the same day as start/end + bool hasEntryOnStartDay = false; + bool hasEntryOnEndDay = false; + + // Track entries for potential interpolation + MeasurementChartEntry? lastBeforeStart; + MeasurementChartEntry? lastBeforeEnd; + + // Single pass through the data + for (final entry in this) { + if (entry.date.isSameDayAs(start)) { + hasEntryOnStartDay = true; + } + if (end != null && entry.date.isSameDayAs(end)) { + hasEntryOnEndDay = true; + } + + if (end != null && entry.date.isBefore(end)) { + lastBeforeEnd = entry; + } + + if (entry.date.isBefore(start)) { + lastBeforeStart = entry; + } else { + // insert interpolated start value if needed + if (!hasEntryOnStartDay && lastBeforeStart != null) { + result.insert(0, interpolateBetween(lastBeforeStart, entry, start)); + hasEntryOnStartDay = true; + } + + if (end == null || entry.date.isBefore(end)) { + result.add(entry); + } + if (end != null && entry.date.isAfter(end)) { + // insert interpolated end value if needed + // note: we only interpolate end if we have data going beyond end + // if let's say your plan ends in a week from now, we wouldn't want to fake data until next week. + if (!hasEntryOnEndDay && lastBeforeEnd != null) { + result.add(interpolateBetween(lastBeforeEnd, entry, end)); + hasEntryOnEndDay = true; + } + // we added all our values and did all interpolations + // surely all input values from here on are irrelevant. + return result; + } + } + } + return result; + } +} + +// caller needs to make sure that before.date < date < after.date +MeasurementChartEntry interpolateBetween( + MeasurementChartEntry before, MeasurementChartEntry after, DateTime date) { + final totalDuration = after.date.difference(before.date).inMilliseconds; + final startDuration = date.difference(before.date).inMilliseconds; + + // Create a special DateTime with milliseconds ending in 123 to mark it as interpolated + // which we leverage in the UI + final markedDate = DateTime( + date.year, + date.month, + date.day, + date.hour, + date.minute, + date.second, + INTERPOLATION_MARKER, + ); + + return MeasurementChartEntry( + before.value + (after.value - before.value) * (startDuration / totalDuration), + markedDate, + ); +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 22c4abae..a3507580 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -386,6 +386,18 @@ "@date": { "description": "The date of a workout log or body weight entry" }, + "creationDate": "Start date", + "@creationDate": { + "description": "The Start date of a nutritional plan" + }, + "endDate": "End date", + "@endDate": { + "description": "The End date of a nutritional plan" + }, + "openEnded": "Open ended", + "@openEnded": { + "description": "When a nutrition plan has no pre-defined end date" + }, "value": "Value", "@value": { "description": "The value of a measurement entry" diff --git a/lib/models/nutrition/nutritional_plan.dart b/lib/models/nutrition/nutritional_plan.dart index 3605e220..65ac4206 100644 --- a/lib/models/nutrition/nutritional_plan.dart +++ b/lib/models/nutrition/nutritional_plan.dart @@ -19,6 +19,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'package:logging/logging.dart'; import 'package:wger/helpers/consts.dart'; import 'package:wger/helpers/json.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; @@ -32,6 +33,8 @@ part 'nutritional_plan.g.dart'; @JsonSerializable(explicitToJson: true) class NutritionalPlan { + final _logger = Logger('NutritionalPlan Model'); + @JsonKey(required: true) int? id; @@ -41,6 +44,12 @@ class NutritionalPlan { @JsonKey(required: true, name: 'creation_date', toJson: dateToUtcIso8601) late DateTime creationDate; + @JsonKey(required: true, name: 'start', toJson: dateToYYYYMMDD) + late DateTime startDate; + + @JsonKey(required: true, name: 'end', toJson: dateToYYYYMMDD) + late DateTime? endDate; + @JsonKey(required: true, name: 'only_logging') late bool onlyLogging; @@ -68,7 +77,9 @@ class NutritionalPlan { NutritionalPlan({ this.id, required this.description, - required this.creationDate, + DateTime? creationDate, + required this.startDate, + this.endDate, this.onlyLogging = false, this.goalEnergy, this.goalProtein, @@ -77,13 +88,23 @@ class NutritionalPlan { this.goalFiber, List? meals, List? diaryEntries, - }) { + }) : creationDate = creationDate ?? DateTime.now() { this.meals = meals ?? []; this.diaryEntries = diaryEntries ?? []; + + if (endDate != null && endDate!.isBefore(startDate)) { + _logger.warning( + 'The end date of a nutritional plan is before the start. Setting to null! ' + 'PlanId: $id, startDate: $startDate, endDate: $endDate', + ); + endDate = null; + } } NutritionalPlan.empty() { creationDate = DateTime.now(); + startDate = DateTime.now(); + endDate = null; description = ''; onlyLogging = false; goalEnergy = null; diff --git a/lib/models/nutrition/nutritional_plan.g.dart b/lib/models/nutrition/nutritional_plan.g.dart index 731befbf..742d3435 100644 --- a/lib/models/nutrition/nutritional_plan.g.dart +++ b/lib/models/nutrition/nutritional_plan.g.dart @@ -13,6 +13,8 @@ NutritionalPlan _$NutritionalPlanFromJson(Map json) { 'id', 'description', 'creation_date', + 'start', + 'end', 'only_logging', 'goal_energy', 'goal_protein', @@ -24,7 +26,10 @@ NutritionalPlan _$NutritionalPlanFromJson(Map json) { return NutritionalPlan( id: (json['id'] as num?)?.toInt(), description: json['description'] as String, - creationDate: DateTime.parse(json['creation_date'] as String), + creationDate: + json['creation_date'] == null ? null : DateTime.parse(json['creation_date'] as String), + startDate: DateTime.parse(json['start'] as String), + endDate: json['end'] == null ? null : DateTime.parse(json['end'] as String), onlyLogging: json['only_logging'] as bool? ?? false, goalEnergy: json['goal_energy'] as num?, goalProtein: json['goal_protein'] as num?, @@ -38,6 +43,8 @@ Map _$NutritionalPlanToJson(NutritionalPlan instance) => + plan.startDate.isBefore(now) && (plan.endDate == null || plan.endDate!.isAfter(now))) + .toList() + .sorted((a, b) => b.creationDate.compareTo(a.creationDate)) + .firstOrNull; } NutritionalPlan findById(int id) { diff --git a/lib/widgets/measurements/charts.dart b/lib/widgets/measurements/charts.dart index 704c3da6..076fa493 100644 --- a/lib/widgets/measurements/charts.dart +++ b/lib/widgets/measurements/charts.dart @@ -82,12 +82,17 @@ class _MeasurementChartWidgetFlState extends State { NumberFormat.decimalPattern(Localizations.localeOf(context).toString()); return touchedSpots.map((touchedSpot) { + final msSinceEpoch = touchedSpot.x.toInt(); final DateTime date = DateTime.fromMillisecondsSinceEpoch(touchedSpot.x.toInt()); final dateStr = DateFormat.Md(Localizations.localeOf(context).languageCode).format(date); + // Check if this is an interpolated point (milliseconds ending with 123) + final bool isInterpolated = msSinceEpoch % 1000 == INTERPOLATION_MARKER; + final String interpolatedMarker = isInterpolated ? ' (interpolated)' : ''; + return LineTooltipItem( - '$dateStr: ${numberFormat.format(touchedSpot.y)} ${widget._unit}', + '$dateStr: ${numberFormat.format(touchedSpot.y)} ${widget._unit}$interpolatedMarker', TextStyle(color: touchedSpot.bar.color), ); }).toList(); diff --git a/lib/widgets/measurements/entries.dart b/lib/widgets/measurements/entries.dart index 8fe2e3a8..78460004 100644 --- a/lib/widgets/measurements/entries.dart +++ b/lib/widgets/measurements/entries.dart @@ -36,7 +36,7 @@ class EntriesList extends StatelessWidget { @override Widget build(BuildContext context) { - final plan = Provider.of(context, listen: false).currentPlan; + final plans = Provider.of(context, listen: false).items; final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString()); final provider = Provider.of(context, listen: false); @@ -49,7 +49,7 @@ class EntriesList extends StatelessWidget { _category.name, entriesAll, entries7dAvg, - plan, + plans, _category.unit, context, ), diff --git a/lib/widgets/measurements/helpers.dart b/lib/widgets/measurements/helpers.dart index 3881b3a7..4a6f05af 100644 --- a/lib/widgets/measurements/helpers.dart +++ b/lib/widgets/measurements/helpers.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:wger/helpers/measurements.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/nutrition/nutritional_plan.dart'; import 'package:wger/widgets/measurements/charts.dart'; @@ -19,7 +20,16 @@ List getOverviewWidgets( Container( padding: const EdgeInsets.all(15), height: 220, - child: MeasurementChartWidgetFl(raw, unit, avgs: avg), + child: raw.isEmpty + ? Center( + child: Text( + 'No data available', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.secondary.withValues(alpha: 0.7), + ), + ), + ) + : MeasurementChartWidgetFl(raw, unit, avgs: avg), ), if (avg.isNotEmpty) MeasurementOverallChangeWidget(avg.first, avg.last, unit), const SizedBox(height: 8), @@ -30,13 +40,11 @@ List getOverviewWidgetsSeries( String name, List entriesAll, List entries7dAvg, - NutritionalPlan? plan, + List plans, String unit, BuildContext context, ) { final monthAgo = DateTime.now().subtract(const Duration(days: 30)); - final showPlan = plan != null && entriesAll.any((e) => e.date.isAfter(plan.creationDate)); - return [ ...getOverviewWidgets( AppLocalizations.of(context).chartAllTimeTitle(name), @@ -45,31 +53,25 @@ List getOverviewWidgetsSeries( unit, context, ), - if (showPlan) + // Show overview widgets for each plan in plans + for (final plan in plans) ...getOverviewWidgets( AppLocalizations.of(context).chartDuringPlanTitle(name, plan.description), - entriesAll.where((e) => e.date.isAfter(plan.creationDate)).toList(), - entries7dAvg.where((e) => e.date.isAfter(plan.creationDate)).toList(), + entriesAll.whereDateWithInterpolation(plan.startDate, plan.endDate), + entries7dAvg.whereDateWithInterpolation(plan.startDate, plan.endDate), unit, context, ), // if all time is significantly longer than 30 days (let's say > 75 days) - // and any plan was also > 75 days, // then let's show a separate chart just focusing on the last 30 days, // if there is data for it. if (entriesAll.isNotEmpty && entriesAll.first.date.isBefore(entriesAll.last.date.subtract(const Duration(days: 75))) && - (plan == null || - (showPlan && - entriesAll - .firstWhere((e) => e.date.isAfter(plan.creationDate)) - .date - .isBefore(entriesAll.last.date.subtract(const Duration(days: 30))))) && entriesAll.any((e) => e.date.isAfter(monthAgo))) ...getOverviewWidgets( AppLocalizations.of(context).chart30DaysTitle(name), - entriesAll.where((e) => e.date.isAfter(monthAgo)).toList(), - entries7dAvg.where((e) => e.date.isAfter(monthAgo)).toList(), + entriesAll.whereDateWithInterpolation(monthAgo, null), + entries7dAvg.whereDateWithInterpolation(monthAgo, null), unit, context, ), @@ -92,7 +94,7 @@ List getOverviewWidgetsSeries( ]; } -// return the raw and average meaasurements for a "sensible range" +// return the raw and average measurements for a "sensible range" // a sensible range is something relatively recent, which is most relevant // for the user to track their progress, but a range should always include // at least 5 points, and if not we chose a bigger one. @@ -104,16 +106,16 @@ List getOverviewWidgetsSeries( final twoMonthsAgo = DateTime.now().subtract(const Duration(days: 61)); final fourMonthsAgo = DateTime.now().subtract(const Duration(days: 122)); - if (entriesAll.where((e) => e.date.isAfter(twoMonthsAgo)).length > 4) { + if (entriesAll.whereDate(twoMonthsAgo, null).length > 4) { return ( - entriesAll.where((e) => e.date.isAfter(twoMonthsAgo)).toList(), - entries7dAvg.where((e) => e.date.isAfter(twoMonthsAgo)).toList(), + entriesAll.whereDate(twoMonthsAgo, null), + entries7dAvg.whereDate(twoMonthsAgo, null), ); } - if (entriesAll.where((e) => e.date.isAfter(fourMonthsAgo)).length > 4) { + if (entriesAll.whereDate(fourMonthsAgo, null).length > 4) { return ( - entriesAll.where((e) => e.date.isAfter(fourMonthsAgo)).toList(), - entries7dAvg.where((e) => e.date.isAfter(fourMonthsAgo)).toList(), + entriesAll.whereDate(fourMonthsAgo, null), + entries7dAvg.whereDate(fourMonthsAgo, null), ); } return (entriesAll, entries7dAvg); diff --git a/lib/widgets/nutrition/forms.dart b/lib/widgets/nutrition/forms.dart index f3bda793..0c4c347e 100644 --- a/lib/widgets/nutrition/forms.dart +++ b/lib/widgets/nutrition/forms.dart @@ -500,20 +500,13 @@ class PlanForm extends StatefulWidget { class _PlanFormState extends State { final _form = GlobalKey(); - bool _onlyLogging = true; GoalType _goalType = GoalType.meals; - - final _descriptionController = TextEditingController(); - final TextEditingController colorController = TextEditingController(); - GoalType? selectedGoal; @override void initState() { super.initState(); - _onlyLogging = widget._plan.onlyLogging; - _descriptionController.text = widget._plan.description; if (widget._plan.hasAnyAdvancedGoals) { _goalType = GoalType.advanced; } else if (widget._plan.hasAnyGoals) { @@ -523,15 +516,10 @@ class _PlanFormState extends State { } } - @override - void dispose() { - _descriptionController.dispose(); - colorController.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { + final dateFormat = DateFormat.yMd(Localizations.localeOf(context).languageCode); + return Form( key: _form, child: ListView( @@ -542,20 +530,116 @@ class _PlanFormState extends State { decoration: InputDecoration( labelText: AppLocalizations.of(context).description, ), - controller: _descriptionController, + controller: TextEditingController( + text: widget._plan.description, + ), onSaved: (newValue) { widget._plan.description = newValue!; }, ), + // Start Date + TextFormField( + key: const Key('field-start-date'), + decoration: InputDecoration( + labelText: AppLocalizations.of(context).startDate, + suffixIcon: const Icon( + Icons.calendar_today, + key: Key('calendarIcon'), + ), + ), + controller: TextEditingController( + text: dateFormat.format(widget._plan.startDate), + ), + readOnly: true, + onTap: () async { + // Stop keyboard from appearing + FocusScope.of(context).requestFocus(FocusNode()); + + // Open date picker + final pickedDate = await showDatePicker( + context: context, + initialDate: widget._plan.startDate, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + + if (pickedDate != null) { + setState(() { + widget._plan.startDate = pickedDate; + }); + } + }, + validator: (value) { + if (widget._plan.endDate != null && + widget._plan.endDate!.isBefore(widget._plan.startDate)) { + return 'End date must be after start date'; + } + + return null; + }, + ), + // End Date + Row( + children: [ + Expanded( + child: TextFormField( + key: const Key('field-end-date'), + decoration: InputDecoration( + labelText: AppLocalizations.of(context).endDate, + helperText: + 'Tip: only for athletes with contest deadlines. Most users benefit from flexibility', + suffixIcon: widget._plan.endDate == null + ? const Icon( + Icons.calendar_today, + key: Key('calendarIcon'), + ) + : IconButton( + icon: const Icon(Icons.clear), + tooltip: 'Clear end date', + onPressed: () { + setState(() { + widget._plan.endDate = null; + }); + }, + ), + ), + controller: TextEditingController( + text: widget._plan.endDate == null + ? '' + : dateFormat.format(widget._plan.endDate!), + ), + readOnly: true, + onTap: () async { + // Stop keyboard from appearing + FocusScope.of(context).requestFocus(FocusNode()); + + // Open date picker + final pickedDate = await showDatePicker( + context: context, + initialDate: widget._plan.endDate, + // end must be after start + firstDate: widget._plan.startDate.add(const Duration(days: 1)), + lastDate: DateTime(2100), + ); + + if (pickedDate != null) { + setState(() { + widget._plan.endDate = pickedDate; + }); + } + }, + ), + ), + ], + ), SwitchListTile( title: Text(AppLocalizations.of(context).onlyLogging), subtitle: Text(AppLocalizations.of(context).onlyLoggingHelpText), - value: _onlyLogging, + value: widget._plan.onlyLogging, onChanged: (value) { setState(() { - _onlyLogging = !_onlyLogging; + widget._plan.onlyLogging = value; }); - widget._plan.onlyLogging = value; }, ), Row( @@ -567,7 +651,7 @@ class _PlanFormState extends State { const SizedBox(width: 8), Expanded( child: DropdownButtonFormField( - value: _goalType, + initialValue: _goalType, items: GoalType.values .map( (e) => DropdownMenuItem( @@ -675,9 +759,6 @@ class _PlanFormState extends State { ); } } - - // Saving was successful, reset the data - _descriptionController.clear(); }, ), ], diff --git a/lib/widgets/nutrition/nutritional_plan_detail.dart b/lib/widgets/nutrition/nutritional_plan_detail.dart index 03c8aed7..b202a30b 100644 --- a/lib/widgets/nutrition/nutritional_plan_detail.dart +++ b/lib/widgets/nutrition/nutritional_plan_detail.dart @@ -17,6 +17,7 @@ */ import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/nutrition/nutritional_plan.dart'; @@ -42,6 +43,26 @@ class NutritionalPlanDetailWidget extends StatelessWidget { return SliverList( delegate: SliverChildListDelegate( [ + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + _nutritionalPlan.endDate != null + ? 'from ${DateFormat.yMd( + Localizations.localeOf(context).languageCode, + ).format(_nutritionalPlan.startDate)} to ${DateFormat.yMd( + Localizations.localeOf(context).languageCode, + ).format(_nutritionalPlan.endDate!)}' + : 'from ${DateFormat.yMd( + Localizations.localeOf(context).languageCode, + ).format(_nutritionalPlan.startDate)} (${AppLocalizations.of(context).openEnded})', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontStyle: FontStyle.italic, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 8), SizedBox( width: 300, child: Padding( diff --git a/lib/widgets/nutrition/nutritional_plans_list.dart b/lib/widgets/nutrition/nutritional_plans_list.dart index 4e682e14..9f45149d 100644 --- a/lib/widgets/nutrition/nutritional_plans_list.dart +++ b/lib/widgets/nutrition/nutritional_plans_list.dart @@ -18,16 +18,74 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import 'package:wger/helpers/measurements.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; +import 'package:wger/providers/body_weight.dart'; import 'package:wger/providers/nutrition.dart'; +import 'package:wger/providers/user.dart'; import 'package:wger/screens/nutritional_plan_screen.dart'; import 'package:wger/widgets/core/text_prompt.dart'; +import 'package:wger/widgets/measurements/charts.dart'; class NutritionalPlansList extends StatelessWidget { final NutritionPlansProvider _nutritionProvider; const NutritionalPlansList(this._nutritionProvider); + /// Builds the weight change information for a nutritional plan period + Widget _buildWeightChangeInfo(BuildContext context, DateTime startDate, DateTime? endDate) { + final provider = Provider.of(context, listen: false); + + final entriesAll = provider.items.map((e) => MeasurementChartEntry(e.weight, e.date)).toList(); + final entries7dAvg = moving7dAverage(entriesAll).whereDateWithInterpolation(startDate, endDate); + if (entries7dAvg.length < 2) { + return const SizedBox.shrink(); + } + + // Calculate weight change + final firstWeight = entries7dAvg.first; + final lastWeight = entries7dAvg.last; + final weightDifference = lastWeight.value - firstWeight.value; + + // Format the weight change text and determine color + final String weightChangeText; + final Color weightChangeColor; + final profile = context.read().profile; + + final unit = weightUnit(profile!.isMetric, context); + + if (weightDifference > 0) { + weightChangeText = '+${weightDifference.toStringAsFixed(1)} $unit'; + weightChangeColor = Colors.red; + } else if (weightDifference < 0) { + weightChangeText = '${weightDifference.toStringAsFixed(1)} $unit'; + weightChangeColor = Colors.green; + } else { + weightChangeText = '0 $unit'; + weightChangeColor = Colors.grey; + } + + return Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Row( + children: [ + Text( + '${AppLocalizations.of(context).weight} change: ', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + weightChangeText, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.bold, + color: weightChangeColor, + ), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { return RefreshIndicator( @@ -48,10 +106,22 @@ class NutritionalPlansList extends StatelessWidget { ); }, title: Text(currentPlan.getLabel(context)), - subtitle: Text( - DateFormat.yMd( - Localizations.localeOf(context).languageCode, - ).format(currentPlan.creationDate), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + currentPlan.endDate != null + ? 'from ${DateFormat.yMd( + Localizations.localeOf(context).languageCode, + ).format(currentPlan.startDate)} to ${DateFormat.yMd( + Localizations.localeOf(context).languageCode, + ).format(currentPlan.endDate!)}' + : 'from ${DateFormat.yMd( + Localizations.localeOf(context).languageCode, + ).format(currentPlan.startDate)} (open ended)', + ), + _buildWeightChangeInfo(context, currentPlan.startDate, currentPlan.endDate), + ], ), trailing: Row(mainAxisSize: MainAxisSize.min, children: [ const VerticalDivider(), diff --git a/lib/widgets/routines/forms/routine.dart b/lib/widgets/routines/forms/routine.dart index 03b5a10a..b2c679e9 100644 --- a/lib/widgets/routines/forms/routine.dart +++ b/lib/widgets/routines/forms/routine.dart @@ -110,7 +110,7 @@ class _RoutineFormState extends State { }, decoration: InputDecoration( labelText: i18n.startDate, - suffixIcon: Icon( + suffixIcon: const Icon( Icons.calendar_today, key: Key('calendarIcon'), ), diff --git a/lib/widgets/weight/weight_overview.dart b/lib/widgets/weight/weight_overview.dart index a60e04b5..0a39fb4b 100644 --- a/lib/widgets/weight/weight_overview.dart +++ b/lib/widgets/weight/weight_overview.dart @@ -38,7 +38,7 @@ class WeightOverview extends StatelessWidget { Widget build(BuildContext context) { final profile = context.read().profile; final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString()); - final plan = Provider.of(context, listen: false).currentPlan; + final plans = Provider.of(context, listen: false).items; final entriesAll = _provider.items.map((e) => MeasurementChartEntry(e.weight, e.date)).toList(); final entries7dAvg = moving7dAverage(entriesAll); @@ -51,7 +51,7 @@ class WeightOverview extends StatelessWidget { AppLocalizations.of(context).weight, entriesAll, entries7dAvg, - plan, + plans, unit, context, ), diff --git a/pubspec.lock b/pubspec.lock index 4acfa56d..f3aa2015 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -744,10 +744,10 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "11.0.1" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: diff --git a/test/fixtures/nutrition/nutritional_plan_detail_response.json b/test/fixtures/nutrition/nutritional_plan_detail_response.json index 42b0ab0f..7a1548c9 100644 --- a/test/fixtures/nutrition/nutritional_plan_detail_response.json +++ b/test/fixtures/nutrition/nutritional_plan_detail_response.json @@ -2,6 +2,8 @@ "id": 1, "creation_date": "2022-08-09", "description": "", + "start": "2022-08-01", + "end": null, "only_logging": false, "goal_energy": null, "goal_protein": null, diff --git a/test/fixtures/nutrition/nutritional_plan_info_detail_response.json b/test/fixtures/nutrition/nutritional_plan_info_detail_response.json index 3c1f0c5e..6d81d3b1 100644 --- a/test/fixtures/nutrition/nutritional_plan_info_detail_response.json +++ b/test/fixtures/nutrition/nutritional_plan_info_detail_response.json @@ -2,6 +2,8 @@ "id": 92547, "creation_date": "2024-04-01", "description": "first real cut", + "start": "2024-04-01", + "end": null, "only_logging": false, "goal_energy": 1600, "goal_protein": 150, diff --git a/test/helpers/measurements_test.dart b/test/helpers/measurements_test.dart new file mode 100644 index 00000000..ddf0886a --- /dev/null +++ b/test/helpers/measurements_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:wger/helpers/consts.dart'; +import 'package:wger/helpers/measurements.dart'; +import 'package:wger/widgets/measurements/charts.dart'; + +void main() { + group('whereDateWithInterpolation', () { + // Helper to create entries + MeasurementChartEntry entry(num value, DateTime date) => MeasurementChartEntry(value, date); + + // Test: No interpolation needed, exact start and end dates exist + test('returns entries within range when start and end exist', () { + final entries = [ + entry(10, DateTime(2023, 1, 1)), + entry(20, DateTime(2023, 1, 2)), + entry(30, DateTime(2023, 1, 3)), + ]; + final result = entries.whereDateWithInterpolation(DateTime(2023, 1, 1), DateTime(2023, 1, 3)); + + // Entries on start and end date should be included if they exist + expect(result.first.value, 10); + expect(result.last.value, 20); + }); + + // Test: Interpolates start if missing + test('interpolates start if missing', () { + final entries = [ + entry(10, DateTime(2023, 1, 1)), + entry(30, DateTime(2023, 1, 3)), + ]; + final result = entries.whereDateWithInterpolation(DateTime(2023, 1, 2), DateTime(2023, 1, 3)); + + // Only the interpolated value for 2nd Jan is included + expect(result.length, 1); + expect(result.first.value, closeTo(20, 0.0001)); + expect(result.first.date.millisecond, INTERPOLATION_MARKER); + expect(result.first.date.day, 2); + }); + + // Test: Interpolates end if missing + test('interpolates end if missing', () { + final entries = [ + entry(10, DateTime(2023, 1, 1)), + entry(30, DateTime(2023, 1, 3)), + ]; + final result = entries.whereDateWithInterpolation(DateTime(2023, 1, 1), DateTime(2023, 1, 2)); + // Should include the entry for 1st Jan and an interpolated value for 2nd Jan + expect(result.length, 2); + expect(result.first.value, 10); + expect(result.first.date.day, 1); + expect(result.last.value, closeTo(20, 0.0001)); + expect(result.last.date.day, 2); + }); + + // Test: No interpolation if out of bounds + test('returns empty if no data in range', () { + final entries = [ + entry(10, DateTime(2023, 1, 1)), + entry(20, DateTime(2023, 1, 2)), + ]; + final result = entries.whereDateWithInterpolation(DateTime(2023, 2, 1), DateTime(2023, 2, 2)); + expect(result, isEmpty); + }); + + // Test: Only start interpolation if data exists before and after + test('does not interpolate if no data before start', () { + final entries = [ + entry(10, DateTime(2023, 1, 2)), + entry(20, DateTime(2023, 1, 3)), + ]; + final result = entries.whereDateWithInterpolation(DateTime(2023, 1, 1), DateTime(2023, 1, 3)); + // No interpolation possible for Jan 1, only entry for Jan 2 is included + expect(result.length, 1); + expect(result.first.date.day, 2); + }); + + // Test: Only end interpolation if data exists before and after + test('does not interpolate if no data after end', () { + final entries = [ + entry(10, DateTime(2023, 1, 1)), + entry(20, DateTime(2023, 1, 2)), + ]; + final result = entries.whereDateWithInterpolation(DateTime(2023, 1, 1), DateTime(2023, 1, 3)); + // No interpolation possible for Jan 3, only entries for Jan 1 and Jan 2 are included + expect(result.length, 2); + expect(result.first.date.day, 1); + expect(result.last.date.day, 2); + }); + }); +} diff --git a/test/measurements/measurement_entries_screen_test.dart b/test/measurements/measurement_entries_screen_test.dart index 5a1cb3ae..c40785b7 100644 --- a/test/measurements/measurement_entries_screen_test.dart +++ b/test/measurements/measurement_entries_screen_test.dart @@ -45,6 +45,7 @@ void main() { mockNutritionPlansProvider = MockNutritionPlansProvider(); when(mockNutritionPlansProvider.currentPlan).thenReturn(null); + when(mockNutritionPlansProvider.items).thenReturn([]); }); Widget createHomeScreen({locale = 'en'}) { diff --git a/test/nutrition/goldens/nutritional_plan_1_default_view.png b/test/nutrition/goldens/nutritional_plan_1_default_view.png index a2063857..f021c9f1 100644 Binary files a/test/nutrition/goldens/nutritional_plan_1_default_view.png and b/test/nutrition/goldens/nutritional_plan_1_default_view.png differ diff --git a/test/nutrition/goldens/nutritional_plan_2_one_meal_with_ingredients.png b/test/nutrition/goldens/nutritional_plan_2_one_meal_with_ingredients.png index d84f28f0..ab57450a 100644 Binary files a/test/nutrition/goldens/nutritional_plan_2_one_meal_with_ingredients.png and b/test/nutrition/goldens/nutritional_plan_2_one_meal_with_ingredients.png differ diff --git a/test/nutrition/goldens/nutritional_plan_3_both_meals_with_ingredients.png b/test/nutrition/goldens/nutritional_plan_3_both_meals_with_ingredients.png index 55b7b850..02b148cd 100644 Binary files a/test/nutrition/goldens/nutritional_plan_3_both_meals_with_ingredients.png and b/test/nutrition/goldens/nutritional_plan_3_both_meals_with_ingredients.png differ diff --git a/test/nutrition/nutrition_provider_test.dart b/test/nutrition/nutrition_provider_test.dart index 2b8fb842..40cae73d 100644 --- a/test/nutrition/nutrition_provider_test.dart +++ b/test/nutrition/nutrition_provider_test.dart @@ -5,12 +5,14 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:wger/database/ingredients/ingredients_database.dart'; import 'package:wger/models/nutrition/ingredient.dart'; +import 'package:wger/models/nutrition/nutritional_plan.dart'; import 'package:wger/providers/nutrition.dart'; import '../fixtures/fixture_reader.dart'; import '../measurements/measurement_provider_test.mocks.dart'; void main() { + final now = DateTime.now(); late NutritionPlansProvider nutritionProvider; late MockWgerBaseProvider mockWgerBaseProvider; late IngredientDatabase database; @@ -110,6 +112,108 @@ void main() { }); }); + group('currentPlan', () { + test('gibt den aktiven Plan zurück, wenn nur einer aktiv ist', () { + final plan = NutritionalPlan( + id: 1, + description: 'Aktiver Plan', + startDate: now.subtract(const Duration(days: 1)), + endDate: now.add(const Duration(days: 1)), + creationDate: now.subtract(const Duration(days: 2)), + ); + nutritionProvider = NutritionPlansProvider(mockWgerBaseProvider, [plan], database: database); + expect(nutritionProvider.currentPlan, equals(plan)); + }); + + test('gibt den neuesten aktiven Plan zurück, wenn mehrere aktiv sind', () { + final olderPlan = NutritionalPlan( + id: 1, + description: 'Älterer aktiver Plan', + startDate: now.subtract(const Duration(days: 10)), + endDate: now.add(const Duration(days: 10)), + creationDate: now.subtract(const Duration(days: 10)), + ); + final newerPlan = NutritionalPlan( + id: 2, + description: 'Neuerer aktiver Plan', + startDate: now.subtract(const Duration(days: 5)), + endDate: now.add(const Duration(days: 5)), + creationDate: now.subtract(const Duration(days: 2)), + ); + nutritionProvider = + NutritionPlansProvider(mockWgerBaseProvider, [olderPlan, newerPlan], database: database); + expect(nutritionProvider.currentPlan, equals(newerPlan)); + }); + }); + + group('currentPlan correctly returns the active plan', () { + test('no plans available -> null', () { + nutritionProvider = NutritionPlansProvider(mockWgerBaseProvider, [], database: database); + expect(nutritionProvider.currentPlan, isNull); + }); + + test('no active plan -> null', () { + final plans = [ + NutritionalPlan( + id: 1, + description: 'plan 1', + startDate: now.subtract(const Duration(days: 30)), + endDate: now.subtract(const Duration(days: 5)), + ), + NutritionalPlan( + id: 2, + description: 'plan 2', + startDate: now.add(const Duration(days: 100)), + endDate: now.add(const Duration(days: 50)), + ), + ]; + nutritionProvider = NutritionPlansProvider(mockWgerBaseProvider, plans, database: database); + expect(nutritionProvider.currentPlan, isNull); + }); + + test('active plan exists -> return it', () { + final plan = NutritionalPlan( + description: 'Active plan', + startDate: now.subtract(const Duration(days: 10)), + endDate: now.add(const Duration(days: 10)), + ); + nutritionProvider = NutritionPlansProvider(mockWgerBaseProvider, [plan], database: database); + expect(nutritionProvider.currentPlan, equals(plan)); + }); + + test('inactive plans are ignored', () { + final inactivePlan = NutritionalPlan( + description: 'Inactive plan', + startDate: now.subtract(const Duration(days: 10)), + endDate: now.add(const Duration(days: 5)), + ); + final plan = NutritionalPlan( + description: 'Active plan', + startDate: now.subtract(const Duration(days: 10)), + endDate: now.add(const Duration(days: 10)), + ); + nutritionProvider = + NutritionPlansProvider(mockWgerBaseProvider, [plan, inactivePlan], database: database); + expect(nutritionProvider.currentPlan, equals(plan)); + }); + + test('several active plans exists -> return newest', () { + final olderPlan = NutritionalPlan( + description: 'Old active plan', + startDate: now.subtract(const Duration(days: 10)), + endDate: now.add(const Duration(days: 10)), + ); + final newerPlan = NutritionalPlan( + description: 'Newer active plan', + startDate: now.subtract(const Duration(days: 5)), + endDate: now.add(const Duration(days: 5)), + ); + nutritionProvider = + NutritionPlansProvider(mockWgerBaseProvider, [olderPlan, newerPlan], database: database); + expect(nutritionProvider.currentPlan, equals(newerPlan)); + }); + }); + group('Ingredient cache DB', () { test('that if there is already valid data in the DB, the API is not hit', () async { // Arrange diff --git a/test/nutrition/nutritional_plan_form_test.dart b/test/nutrition/nutritional_plan_form_test.dart index 5c56634d..5b67fdf6 100644 --- a/test/nutrition/nutritional_plan_form_test.dart +++ b/test/nutrition/nutritional_plan_form_test.dart @@ -37,6 +37,8 @@ void main() { final plan1 = NutritionalPlan( id: 1, creationDate: DateTime(2021, 1, 1), + startDate: DateTime(2021, 1, 1), + endDate: DateTime(2021, 2, 10), description: 'test plan 1', ); final plan2 = NutritionalPlan.empty(); @@ -70,7 +72,7 @@ void main() { await tester.pumpWidget(createHomeScreen(plan1)); await tester.pumpAndSettle(); - expect(find.byType(TextFormField), findsOneWidget); + expect(find.byType(TextFormField), findsNWidgets(3)); expect(find.byType(ElevatedButton), findsOneWidget); expect(find.byKey(const Key(SUBMIT_BUTTON_KEY_NAME)), findsOneWidget); }); @@ -79,11 +81,10 @@ void main() { await tester.pumpWidget(createHomeScreen(plan1)); await tester.pumpAndSettle(); - expect( - find.text('test plan 1'), - findsOneWidget, - reason: 'Description of existing nutritional plan is filled in', - ); + expect(find.text('test plan 1'), findsOneWidget, reason: 'Description is filled in'); + expect(find.text('1/1/2021'), findsOneWidget, reason: 'Start date is filled in'); + expect(find.text('2/10/2021'), findsOneWidget, reason: 'End date is filled in'); + await tester.enterText(find.byKey(const Key('field-description')), 'New description'); await tester.tap(find.byKey(const Key(SUBMIT_BUTTON_KEY_NAME))); @@ -109,7 +110,9 @@ void main() { await tester.pumpWidget(createHomeScreen(plan2)); await tester.pumpAndSettle(); - expect(find.text(''), findsOneWidget, reason: 'New nutritional plan has no description'); + expect(find.text(''), findsNWidgets(2), + reason: 'New nutritional plan needs description, and end date'); + // there's also the start date, but it will have a value depending on 'now' await tester.enterText(find.byKey(const Key('field-description')), 'New cool plan'); await tester.tap(find.byKey(const Key(SUBMIT_BUTTON_KEY_NAME))); diff --git a/test/nutrition/nutritional_plan_model_test.dart b/test/nutrition/nutritional_plan_model_test.dart index ac35a162..40069d16 100644 --- a/test/nutrition/nutritional_plan_model_test.dart +++ b/test/nutrition/nutritional_plan_model_test.dart @@ -51,6 +51,7 @@ void main() { NutritionalPlan( description: '3 macros and energy defined', creationDate: DateTime(2024, 5, 4), + startDate: DateTime(2024, 5, 4), goalProtein: 150, goalCarbohydrates: 100, goalFat: 100, @@ -69,6 +70,7 @@ void main() { NutritionalPlan( description: '2 macros and energy defined', creationDate: DateTime(2024, 5, 4), + startDate: DateTime(2024, 5, 4), goalProtein: 100, goalCarbohydrates: 100, goalEnergy: 1700, @@ -86,6 +88,7 @@ void main() { NutritionalPlan( description: '3 macros defined', creationDate: DateTime(2024, 5, 4), + startDate: DateTime(2024, 5, 4), goalProtein: 100, goalCarbohydrates: 100, goalFat: 10, diff --git a/test/nutrition/nutritional_plans_screen_test.dart b/test/nutrition/nutritional_plans_screen_test.dart index d3ff7657..042efd5b 100644 --- a/test/nutrition/nutritional_plans_screen_test.dart +++ b/test/nutrition/nutritional_plans_screen_test.dart @@ -20,15 +20,20 @@ import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; +import 'package:intl/intl.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import 'package:path/path.dart'; import 'package:provider/provider.dart'; import 'package:wger/database/ingredients/ingredients_database.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/nutrition/nutritional_plan.dart'; +import 'package:wger/models/user/profile.dart'; import 'package:wger/providers/auth.dart'; import 'package:wger/providers/base_provider.dart'; +import 'package:wger/providers/body_weight.dart'; import 'package:wger/providers/nutrition.dart'; +import 'package:wger/providers/user.dart'; import 'package:wger/screens/form_screen.dart'; import 'package:wger/screens/nutritional_plans_screen.dart'; import 'package:wger/widgets/nutrition/forms.dart'; @@ -62,23 +67,43 @@ void main() { when(mockAuthProvider.serverUrl).thenReturn('http://localhost'); when(mockAuthProvider.getAppNameHeader()).thenReturn('wger app'); - return ChangeNotifierProvider( - create: (context) => NutritionPlansProvider( - mockBaseProvider, - [ - NutritionalPlan( - id: 1, - description: 'test plan 1', - creationDate: DateTime(2021, 01, 01), + return MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (context) => NutritionPlansProvider( + mockBaseProvider, + [ + NutritionalPlan( + id: 1, + description: 'test plan 1', + creationDate: DateTime(2021, 01, 01), + startDate: DateTime(2021, 01, 01), + ), + NutritionalPlan( + id: 2, + description: 'test plan 2', + creationDate: DateTime(2021, 01, 10), + startDate: DateTime(2021, 01, 10), + ), + ], + database: database, ), - NutritionalPlan( - id: 2, - description: 'test plan 2', - creationDate: DateTime(2021, 01, 10), - ), - ], - database: database, - ), + ), + ChangeNotifierProvider( + create: (context) => BodyWeightProvider(mockBaseProvider), + ), + ChangeNotifierProvider( + create: (context) => UserProvider( + mockBaseProvider, + )..profile = Profile( + username: 'test', + emailVerified: true, + isTrustworthy: true, + email: 'test@example.com', + weightUnitStr: 'kg', + ), + ), + ], child: MaterialApp( locale: Locale(locale), localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -126,14 +151,16 @@ void main() { testWidgets('Tests the localization of dates - EN', (WidgetTester tester) async { await tester.pumpWidget(createHomeScreen()); - expect(find.text('1/1/2021'), findsOneWidget); - expect(find.text('1/10/2021'), findsOneWidget); +// note .. "(open ended)" at the time, depending on localisation strings + expect(find.textContaining('from 1/1/2021 ('), findsOneWidget); + expect(find.textContaining('from 1/10/2021 ('), findsOneWidget); }); testWidgets('Tests the localization of dates - DE', (WidgetTester tester) async { await tester.pumpWidget(createHomeScreen(locale: 'de')); +// note .. "(open ended)" at the time, depending on localisation strings - expect(find.text('1.1.2021'), findsOneWidget); - expect(find.text('10.1.2021'), findsOneWidget); + expect(find.textContaining('from 1.1.2021 ('), findsOneWidget); + expect(find.textContaining('from 10.1.2021 ('), findsOneWidget); }); } diff --git a/test/weight/weight_screen_test.dart b/test/weight/weight_screen_test.dart index 8c057a5b..9488ad12 100644 --- a/test/weight/weight_screen_test.dart +++ b/test/weight/weight_screen_test.dart @@ -50,6 +50,7 @@ void main() { mockNutritionPlansProvider = MockNutritionPlansProvider(); when(mockNutritionPlansProvider.currentPlan).thenReturn(null); + when(mockNutritionPlansProvider.items).thenReturn([]); }); Widget createWeightScreen({locale = 'en'}) { diff --git a/test_data/nutritional_plans.dart b/test_data/nutritional_plans.dart index 1b3a42cc..a4fbaa21 100644 --- a/test_data/nutritional_plans.dart +++ b/test_data/nutritional_plans.dart @@ -175,6 +175,7 @@ NutritionalPlan getNutritionalPlan() { id: 1, description: 'Less fat, more protein', creationDate: DateTime(2021, 5, 23), + startDate: DateTime(2021, 5, 23), ); plan.meals = [meal1, meal2]; @@ -213,6 +214,7 @@ NutritionalPlan getNutritionalPlanScreenshot() { id: 1, description: 'Diet', creationDate: DateTime(2021, 5, 23), + startDate: DateTime(2021, 5, 23), meals: [meal1, meal2], );