• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

wger-project / flutter / 30854254088

03 Aug 2026 09:21PM UTC coverage: 40.895% (-15.0%) from 55.93%
30854254088

Pull #1260

github

web-flow
Merge d93794de5 into e3affdd4f
Pull Request #1260: WIP: health sync

9980 of 24404 relevant lines covered (40.89%)

1.72 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

57.1
/lib/features/nutrition/widgets/forms.dart
1
/*
2
 * This file is part of wger Workout Manager <https://github.com/wger-project>.
3
 * Copyright (c) 2020 - 2026 wger Team
4
 *
5
 * wger Workout Manager is free software: you can redistribute it and/or modify
6
 * it under the terms of the GNU Affero General Public License as published by
7
 * the Free Software Foundation, either version 3 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13
 * GNU Affero General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU Affero General Public License
16
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
17
 */
18

19
import 'package:flutter/material.dart';
20
import 'package:flutter_riverpod/flutter_riverpod.dart';
21
import 'package:wger/core/consts.dart';
22
import 'package:wger/core/formatting/formatting.dart';
23
import 'package:wger/core/number_input.dart';
24
import 'package:wger/core/snackbar.dart';
25
import 'package:wger/core/validators.dart';
26
import 'package:wger/core/widgets/datetime_input.dart';
27
import 'package:wger/core/widgets/decimal_input.dart';
28
import 'package:wger/core/widgets/form_submit_button.dart';
29
import 'package:wger/features/nutrition/models/ingredient.dart';
30
import 'package:wger/features/nutrition/models/ingredient_weight_unit.dart';
31
import 'package:wger/features/nutrition/models/log.dart';
32
import 'package:wger/features/nutrition/models/meal.dart';
33
import 'package:wger/features/nutrition/models/meal_item.dart';
34
import 'package:wger/features/nutrition/models/nutritional_plan.dart';
35
import 'package:wger/features/nutrition/providers/nutrition_notifier.dart';
36
import 'package:wger/features/nutrition/screens/nutritional_plan_screen.dart';
37
import 'package:wger/features/nutrition/widgets/helpers.dart';
38
import 'package:wger/features/nutrition/widgets/nutrition_tiles.dart';
39
import 'package:wger/features/nutrition/widgets/widgets.dart';
40
import 'package:wger/l10n/generated/app_localizations.dart';
41

42
class MealForm extends ConsumerWidget {
43
  late final Meal _meal;
44
  final String _planId;
45

46
  final _form = GlobalKey<FormState>();
47
  final _nameController = TextEditingController();
48

49
  MealForm(this._planId, [meal]) {
1✔
50
    _meal = meal ?? Meal(plan: _planId, time: TimeOfDay.fromDateTime(DateTime.now()));
1✔
51
    _nameController.text = _meal.name;
4✔
52
  }
53

54
  @override
1✔
55
  Widget build(BuildContext context, WidgetRef ref) {
56
    final isCreating = _meal.id == null;
2✔
57

58
    return Container(
1✔
59
      margin: const EdgeInsets.all(20),
60
      child: Form(
1✔
61
        key: _form,
1✔
62
        child: Column(
1✔
63
          children: [
1✔
64
            TimeInputWidget(
1✔
65
              key: const Key('field-time'),
66
              value: _meal.time,
2✔
67
              labelText: AppLocalizations.of(context).time,
2✔
68
              onChanged: (time) => _meal.time = time,
×
69
            ),
70
            TextFormField(
1✔
71
              maxLength: 25,
72
              key: const Key('field-name'),
73
              decoration: InputDecoration(labelText: AppLocalizations.of(context).name),
3✔
74
              controller: _nameController,
1✔
75
              onSaved: (newValue) {
×
76
                _meal.name = newValue as String;
×
77
              },
78
            ),
79
            FormSubmitButton(
1✔
80
              key: const Key(SUBMIT_BUTTON_KEY_NAME),
81
              label: AppLocalizations.of(context).save,
2✔
82
              onPressed: () async {
×
83
                if (!_form.currentState!.validate()) {
×
84
                  return;
85
                }
86
                _form.currentState!.save();
×
87

88
                final notifier = ref.read(nutritionProvider.notifier);
×
89
                isCreating
90
                    ? await notifier.addMeal(_meal, _planId)
×
91
                    : await notifier.editMeal(_meal);
×
92

93
                if (context.mounted) {
×
94
                  Navigator.of(context).pop();
×
95
                }
96
              },
97
            ),
98
          ],
99
        ),
100
      ),
101
    );
102
  }
103
}
104

105
Widget getMealItemForm(
1✔
106
  Meal meal,
107
  List<MealItem> recent, [
108
  String? barcode,
109
  bool? test,
110
]) {
111
  return IngredientForm(
1✔
112
    // The recent list is ephemeral display data, planId isn't used for
113
    // persistence, so an empty sentinel is fine.
114
    recent: recent.map((e) => LogItem.fromMealItem(e, '', e.mealId)).toList(),
2✔
115
    onSave: (BuildContext context, WidgetRef ref, MealItem mealItem, DateTime? dt, String? mealId) {
1✔
116
      mealItem.mealId = meal.id!;
2✔
117
      ref.read(nutritionProvider.notifier).addMealItem(mealItem, meal);
4✔
118
    },
119
    barcode: barcode ?? '',
120
    test: test ?? false,
121
    withDate: false,
122
  );
123
}
124

125
Widget getIngredientLogForm(NutritionalPlan plan) {
×
126
  return IngredientForm(
×
127
    recent: plan.dedupDiaryEntries,
×
128
    meals: plan.meals,
×
129
    onSave: (BuildContext context, WidgetRef ref, MealItem mealItem, DateTime? dt, String? mealId) {
×
130
      ref.read(nutritionProvider.notifier).logIngredientToDiary(mealItem, plan.id!, dt, mealId);
×
131
      showSnackbar(context, AppLocalizations.of(context).ingredientLogged, center: true);
×
132
    },
133
    withDate: true,
134
  );
135
}
136

137
/// IngredientForm is a form that lets the user pick an ingredient (and amount) to
138
/// log to the diary or to add to a meal.
139
class IngredientForm extends ConsumerStatefulWidget {
140
  final Function(
141
    BuildContext context,
142
    WidgetRef ref,
143
    MealItem mealItem,
144
    DateTime? dt,
145
    String? mealId,
146
  )
147
  onSave;
148
  final List<LogItem> recent;
149
  final bool withDate;
150
  final String barcode;
151
  final bool test;
152

153
  /// When not empty, the form offers to assign the entry to one of these meals
154
  final List<Meal> meals;
155

156
  const IngredientForm({
1✔
157
    required this.recent,
158
    required this.onSave,
159
    required this.withDate,
160
    this.barcode = '',
161
    this.test = false,
162
    this.meals = const [],
163
  });
164

165
  @override
1✔
166
  ConsumerState<IngredientForm> createState() => IngredientFormState();
1✔
167
}
168

169
class IngredientFormState extends ConsumerState<IngredientForm> {
170
  final _form = GlobalKey<FormState>();
171
  final _ingredientController = TextEditingController();
172
  final _ingredientIdController = TextEditingController();
173
  final _amountController = TextEditingController();
174
  DateTime _date = DateTime.now();
175
  TimeOfDay _time = TimeOfDay.now();
176
  final _mealItem = MealItem.empty();
177
  var _searchQuery = ''; // copy from typeahead. for filtering suggestions
178
  List<IngredientWeightUnit> _weightUnits = [];
179
  IngredientWeightUnit? _selectedWeightUnit;
180
  String? _selectedMealId;
181

182
  @override
1✔
183
  void dispose() {
184
    _ingredientController.dispose();
2✔
185
    _ingredientIdController.dispose();
2✔
186
    _amountController.dispose();
2✔
187
    super.dispose();
1✔
188
  }
189

190
  TextEditingController get ingredientIdController => _ingredientIdController;
2✔
191

192
  MealItem get mealItem => _mealItem;
2✔
193

194
  void selectIngredient(Ingredient ingredient, num? amount) {
1✔
195
    setState(() {
2✔
196
      _mealItem.ingredient = ingredient;
2✔
197
      _mealItem.ingredientId = ingredient.id;
3✔
198
      _ingredientController.text = ingredient.name;
3✔
199
      _ingredientIdController.text = ingredient.id.toString();
4✔
200
      if (amount != null) {
201
        _amountController.text = amount.toStringAsFixed(0);
×
202
        _mealItem.amount = amount;
×
203
      }
204
      _selectedWeightUnit = null;
1✔
205
      _mealItem.weightUnitId = null;
2✔
206
      _mealItem.weightUnitObj = null;
2✔
207
      _weightUnits = ingredient.weightUnits;
2✔
208
    });
209
  }
210

211
  // note: does not reset text search and amount inputs
212
  void unSelectIngredient() {
×
213
    setState(() {
×
214
      _mealItem.ingredientId = 0;
×
215
      _ingredientIdController.text = '';
×
216
    });
217
  }
218

219
  void updateSearchQuery(String query) {
×
220
    setState(() {
×
221
      _searchQuery = query;
×
222
    });
223
  }
224

225
  @override
1✔
226
  Widget build(BuildContext context) {
227
    final i18n = AppLocalizations.of(context);
1✔
228

229
    final String unit = i18n.g;
1✔
230
    final queryLower = _searchQuery.toLowerCase();
2✔
231
    // Drop suggestions whose ingredient is still being hydrated by PowerSync.
232
    // Without the row they would only render as "…", which is useless to pick.
233
    final suggestions = widget.recent
2✔
234
        .where((e) => e.ingredient?.name.toLowerCase().contains(queryLower) ?? false)
1✔
235
        .toList();
1✔
236
    final numberFormat = localizedNumberFormat(context);
1✔
237

238
    return Container(
1✔
239
      margin: const EdgeInsets.all(20),
240
      child: Form(
1✔
241
        key: _form,
1✔
242
        child: Column(
1✔
243
          children: [
1✔
244
            IngredientTypeahead(
1✔
245
              _ingredientIdController,
1✔
246
              _ingredientController,
1✔
247
              barcode: widget.barcode,
2✔
248
              test: widget.test,
2✔
249
              selectIngredient: selectIngredient,
1✔
250
              onDeselectIngredient: unSelectIngredient,
1✔
251
              onUpdateSearchQuery: updateSearchQuery,
1✔
252
            ),
253
            Row(
1✔
254
              children: [
1✔
255
                Expanded(
1✔
256
                  child: TextFormField(
1✔
257
                    key: const Key('field-weight'),
258
                    decoration: InputDecoration(
1✔
259
                      labelText: i18n.weight,
1✔
260
                      suffix: _weightUnits.isNotEmpty
2✔
261
                          ? DropdownButton<int?>(
×
262
                              value: _selectedWeightUnit?.id,
×
263
                              underline: const SizedBox(),
264
                              isDense: true,
265
                              items: [
×
266
                                DropdownMenuItem<int?>(
×
267
                                  value: null,
268
                                  child: Text(i18n.g),
×
269
                                ),
270
                                ..._weightUnits.map(
×
271
                                  (unit) => DropdownMenuItem<int?>(
×
272
                                    value: unit.id,
×
273
                                    child: Text('${unit.name} (${unit.grams}g)'),
×
274
                                  ),
275
                                ),
276
                              ],
277
                              onChanged: (value) {
×
278
                                setState(() {
×
279
                                  if (value == null) {
280
                                    _selectedWeightUnit = null;
×
281
                                    _mealItem.weightUnitId = null;
×
282
                                    _mealItem.weightUnitObj = null;
×
283
                                  } else {
284
                                    _selectedWeightUnit = _weightUnits.firstWhere(
×
285
                                      (u) => u.id == value,
×
286
                                    );
287
                                    _mealItem.weightUnitId = value;
×
288
                                    _mealItem.weightUnitObj = _selectedWeightUnit;
×
289
                                  }
290
                                });
291
                              },
292
                            )
293
                          : Text(i18n.g),
2✔
294
                    ),
295
                    controller: _amountController,
1✔
296
                    keyboardType: textInputTypeDecimal,
297
                    inputFormatters: [
1✔
298
                      LocalizedDecimalInputFormatter(numberFormat.symbols.DECIMAL_SEP),
3✔
299
                    ],
300
                    onChanged: (value) {
1✔
301
                      setState(() {
2✔
302
                        final v = numberFormat.tryParse(value);
1✔
303
                        if (v != null) {
304
                          _mealItem.amount = v;
2✔
305
                        }
306
                      });
307
                    },
308
                    onSaved: (value) {
1✔
309
                      _mealItem.amount = numberFormat.parse(value!);
3✔
310
                    },
311
                    validator: (value) {
1✔
312
                      final text = value?.trim() ?? '';
1✔
313
                      if (text.isEmpty) {
1✔
314
                        return i18n.enterValue;
1✔
315
                      }
316
                      final parsed = numberFormat.tryParse(text);
1✔
317
                      if (parsed == null) {
318
                        return i18n.enterValidNumber;
×
319
                      }
320

321
                      if (parsed < 1 || parsed > 1000) {
2✔
322
                        return i18n.formMinMaxValues(1, 1000);
×
323
                      }
324
                      return null;
325
                    },
326
                  ),
327
                ),
328
                if (widget.withDate)
2✔
329
                  Expanded(
×
330
                    child: DateInputWidget(
×
331
                      value: _date,
×
332
                      labelText: i18n.date,
×
333
                      firstDate: DateTime(DateTime.now().year - 10),
×
334
                      lastDate: DateTime.now(),
×
335
                      onChanged: (date) => _date = date,
×
336
                    ),
337
                  ),
338
                if (widget.withDate)
2✔
339
                  Expanded(
×
340
                    child: TimeInputWidget(
×
341
                      key: const Key('field-time'),
342
                      value: _time,
×
343
                      labelText: i18n.time,
×
344
                      onChanged: (time) => _time = time,
×
345
                    ),
346
                  ),
347
              ],
348
            ),
349
            if (widget.meals.isNotEmpty)
3✔
350
              DropdownButtonFormField<String?>(
×
351
                key: const Key('field-meal'),
352
                initialValue: _selectedMealId,
×
353
                decoration: InputDecoration(labelText: i18n.meal),
×
354
                items: [
×
355
                  DropdownMenuItem<String?>(
×
356
                    value: null,
357
                    child: Text(i18n.otherLogs),
×
358
                  ),
359
                  ...widget.meals.map(
×
360
                    (meal) => DropdownMenuItem<String?>(
×
361
                      value: meal.id,
×
362
                      child: Text(
×
363
                        meal.name.isNotEmpty ? meal.name : meal.time?.format(context) ?? '',
×
364
                      ),
365
                    ),
366
                  ),
367
                ],
368
                onChanged: (value) => setState(() => _selectedMealId = value),
×
369
              ),
370
            if (ingredientIdController.text.isNotEmpty &&
3✔
371
                _amountController.text.isNotEmpty &&
3✔
372
                _mealItem.ingredient != null)
2✔
373
              Padding(
1✔
374
                padding: const EdgeInsets.all(8.0),
375
                child: Column(
1✔
376
                  children: [
1✔
377
                    Text(
1✔
378
                      'Macros preview', // TODO fix l10n
379
                      style: Theme.of(context).textTheme.titleMedium,
3✔
380
                    ),
381
                    MealItemValuesTile(
1✔
382
                      ingredient: _mealItem.ingredient!,
2✔
383
                      nutritionalValues: _mealItem.nutritionalValues,
2✔
384
                    ),
385
                  ],
386
                ),
387
              ),
388
            FormSubmitButton(
1✔
389
              key: const Key(SUBMIT_BUTTON_KEY_NAME),
390
              label: AppLocalizations.of(context).save,
2✔
391
              onPressed: () async {
1✔
392
                if (!_form.currentState!.validate()) {
3✔
393
                  return;
394
                }
395
                _form.currentState!.save();
3✔
396
                _mealItem.ingredientId = int.parse(_ingredientIdController.text);
5✔
397

398
                final loggedDate = DateTime(
1✔
399
                  _date.year,
2✔
400
                  _date.month,
2✔
401
                  _date.day,
2✔
402
                  _time.hour,
2✔
403
                  _time.minute,
2✔
404
                );
405
                widget.onSave(context, ref, _mealItem, loggedDate, _selectedMealId);
6✔
406

407
                Navigator.of(context).pop();
2✔
408
              },
409
            ),
410
            if (suggestions.isNotEmpty) const SizedBox(height: 10.0),
1✔
411
            Container(
1✔
412
              padding: const EdgeInsets.all(10.0),
413
              child: Text(AppLocalizations.of(context).recentlyUsedIngredients),
3✔
414
            ),
415
            Expanded(
1✔
416
              child: ListView.builder(
1✔
417
                itemCount: suggestions.length,
1✔
418
                shrinkWrap: true,
419
                itemBuilder: (context, index) {
×
420
                  // ingredient is non-null here, the suggestions list above
421
                  // filters out items whose ingredient hasn't been hydrated.
422
                  final ingredient = suggestions[index].ingredient!;
×
423
                  void select() {
×
424
                    selectIngredient(
×
425
                      ingredient,
426
                      suggestions[index].amount,
×
427
                    );
428
                  }
429

430
                  return Card(
×
431
                    child: ListTile(
×
432
                      onTap: select,
433
                      title: Text(
×
434
                        suggestions[index].weightUnitObj != null
×
435
                            ? '${ingredient.name} (${suggestions[index].amount.toStringAsFixed(0)} × ${suggestions[index].weightUnitObj!.name})'
×
436
                            : '${ingredient.name} (${suggestions[index].amount.toStringAsFixed(0)}$unit)',
×
437
                      ),
438
                      subtitle: Text(
×
439
                        getShortNutritionValues(
×
440
                          ingredient.nutritionalValues,
×
441
                          context,
442
                        ),
443
                      ),
444
                      trailing: Row(
×
445
                        mainAxisSize: MainAxisSize.min,
446
                        children: [
×
447
                          IconButton(
×
448
                            icon: const Icon(Icons.info_outline),
449
                            onPressed: () {
×
450
                              showIngredientDetails(
×
451
                                context,
452
                                ref,
×
453
                                ingredient,
454
                                select: select,
455
                              );
456
                            },
457
                          ),
458
                          const SizedBox(width: 5),
459
                          const Icon(Icons.copy),
460
                        ],
461
                      ),
462
                    ),
463
                  );
464
                },
465
              ),
466
            ),
467
          ],
468
        ),
469
      ),
470
    );
471
  }
472
}
473

474
enum GoalType {
475
  meals('From meals'),
476
  basic('Basic'),
477
  advanced('Advanced');
478

479
  const GoalType(this.label);
480

481
  final String label;
482

483
  String getI18nLabel(BuildContext context) {
1✔
484
    switch (this) {
485
      case GoalType.meals:
1✔
486
        return AppLocalizations.of(context).goalTypeMeals;
2✔
487
      case GoalType.basic:
1✔
488
        return AppLocalizations.of(context).goalTypeBasic;
2✔
489
      case GoalType.advanced:
1✔
490
        return AppLocalizations.of(context).goalTypeAdvanced;
2✔
491
    }
492
  }
493
}
494

495
class PlanForm extends ConsumerStatefulWidget {
496
  // Mutated in-place by descendant onSaved/onTap callbacks (e.g. setting
497
  // [NutritionalPlan.description] or [startDate]); the field reference itself
498
  // is fixed at construction so the widget stays @immutable-compatible.
499
  final NutritionalPlan _plan;
500

501
  PlanForm([NutritionalPlan? plan]) : _plan = plan ?? NutritionalPlan.empty();
1✔
502

503
  @override
1✔
504
  ConsumerState<PlanForm> createState() => _PlanFormState();
1✔
505
}
506

507
class _PlanFormState extends ConsumerState<PlanForm> {
508
  final _form = GlobalKey<FormState>();
509

510
  GoalType _goalType = GoalType.meals;
511
  GoalType? selectedGoal;
512

513
  @override
1✔
514
  void initState() {
515
    super.initState();
1✔
516

517
    if (widget._plan.hasAnyAdvancedGoals) {
3✔
518
      _goalType = GoalType.advanced;
×
519
    } else if (widget._plan.hasAnyGoals) {
3✔
520
      _goalType = GoalType.basic;
×
521
    } else {
522
      _goalType = GoalType.meals;
1✔
523
    }
524
  }
525

526
  @override
1✔
527
  Widget build(BuildContext context) {
528
    final isCreating = widget._plan.id == null;
3✔
529

530
    return Form(
1✔
531
      key: _form,
1✔
532
      child: ListView(
1✔
533
        children: [
1✔
534
          // Description
535
          TextFormField(
1✔
536
            key: const Key('field-description'),
537
            decoration: InputDecoration(
1✔
538
              labelText: AppLocalizations.of(context).description,
2✔
539
            ),
540
            controller: TextEditingController(
1✔
541
              text: widget._plan.description,
3✔
542
            ),
543
            maxLength: NutritionalPlan.maxDescriptionChars,
544
            onSaved: (newValue) {
1✔
545
              widget._plan.description = newValue!;
3✔
546
            },
547
            validator: (value) {
1✔
548
              if (value != null && value.length > NutritionalPlan.maxDescriptionChars) {
2✔
549
                return AppLocalizations.of(
×
550
                  context,
551
                ).enterMaxCharacters(NutritionalPlan.maxDescriptionChars.toString());
×
552
              }
553
              return null;
554
            },
555
          ),
556
          // Start Date
557
          DateInputWidget(
1✔
558
            key: const Key('field-start-date'),
559
            value: widget._plan.startDate,
3✔
560
            labelText: AppLocalizations.of(context).startDate,
2✔
561
            firstDate: DateTime(2000),
1✔
562
            lastDate: DateTime(2100),
1✔
563
            onChanged: (pickedDate) {
×
564
              setState(() {
×
565
                widget._plan.startDate = pickedDate;
×
566
              });
567
            },
568
            validator: (value) => validateDateRange(
2✔
569
              widget._plan.startDate,
3✔
570
              widget._plan.endDate,
3✔
571
              AppLocalizations.of(context),
1✔
572
            ),
573
          ),
574
          // End Date
575
          Row(
1✔
576
            children: [
1✔
577
              Expanded(
1✔
578
                child: DateInputWidget(
1✔
579
                  key: const Key('field-end-date'),
580
                  value: widget._plan.endDate,
3✔
581
                  labelText: AppLocalizations.of(context).endDate,
2✔
582
                  helperText:
583
                      'Tip: only for athletes with contest deadlines.  Most users benefit from flexibility',
584
                  firstDate: widget._plan.startDate.add(const Duration(days: 1)),
4✔
585
                  lastDate: DateTime(2100),
1✔
586
                  onChanged: (pickedDate) {
×
587
                    setState(() {
×
588
                      widget._plan.endDate = pickedDate;
×
589
                    });
590
                  },
591
                  onCleared: () {
×
592
                    setState(() {
×
593
                      widget._plan.endDate = null;
×
594
                    });
595
                  },
596
                ),
597
              ),
598
            ],
599
          ),
600
          SwitchListTile(
1✔
601
            title: Text(AppLocalizations.of(context).onlyLogging),
3✔
602
            subtitle: Text(AppLocalizations.of(context).onlyLoggingHelpText),
3✔
603
            value: widget._plan.onlyLogging,
3✔
604
            onChanged: (value) {
×
605
              setState(() {
×
606
                widget._plan.onlyLogging = value;
×
607
              });
608
            },
609
          ),
610
          Row(
1✔
611
            children: [
1✔
612
              Text(
1✔
613
                AppLocalizations.of(context).goalMacro,
2✔
614
                style: Theme.of(context).textTheme.titleMedium,
3✔
615
              ),
616
              const SizedBox(width: 8),
617
              Expanded(
1✔
618
                child: DropdownButtonFormField<GoalType>(
1✔
619
                  initialValue: _goalType,
1✔
620
                  items: GoalType.values
621
                      .map(
1✔
622
                        (e) => DropdownMenuItem<GoalType>(
2✔
623
                          value: e,
624
                          child: Text(e.getI18nLabel(context)),
2✔
625
                        ),
626
                      )
627
                      .toList(),
1✔
628
                  onChanged: (GoalType? g) {
×
629
                    setState(() {
×
630
                      if (g == null) {
631
                        return;
632
                      }
633
                      switch (g) {
634
                        case GoalType.meals:
×
635
                          widget._plan.goalEnergy = null;
×
636
                          widget._plan.goalProtein = null;
×
637
                          widget._plan.goalCarbohydrates = null;
×
638
                          widget._plan.goalFat = null;
×
639
                          widget._plan.goalFiber = null;
×
640
                        case GoalType.basic:
×
641
                          widget._plan.goalFiber = null;
×
642
                          break;
643
                        default:
644
                          break;
645
                      }
646
                      _goalType = g;
×
647
                    });
648
                  },
649
                ),
650
              ),
651
            ],
652
          ),
653
          if (_goalType == GoalType.basic || _goalType == GoalType.advanced)
4✔
654
            Column(
×
655
              children: [
×
656
                DecimalInputWidget(
×
657
                  key: const Key('field-goal-energy'),
658
                  value: widget._plan.goalEnergy,
×
659
                  labelText: AppLocalizations.of(context).goalEnergy,
×
660
                  suffixText: AppLocalizations.of(context).kcal,
×
661
                  min: 0,
662
                  max: NutritionalPlan.maxGoalEnergy,
663
                  onChanged: (value) => widget._plan.goalEnergy = value,
×
664
                ),
665
                DecimalInputWidget(
×
666
                  key: const Key('field-goal-protein'),
667
                  value: widget._plan.goalProtein,
×
668
                  labelText: AppLocalizations.of(context).goalProtein,
×
669
                  suffixText: AppLocalizations.of(context).g,
×
670
                  min: 0,
671
                  max: NutritionalPlan.maxGoalProtein,
672
                  onChanged: (value) => widget._plan.goalProtein = value,
×
673
                ),
674
                DecimalInputWidget(
×
675
                  key: const Key('field-goal-carbohydrates'),
676
                  value: widget._plan.goalCarbohydrates,
×
677
                  labelText: AppLocalizations.of(context).goalCarbohydrates,
×
678
                  suffixText: AppLocalizations.of(context).g,
×
679
                  min: 0,
680
                  max: NutritionalPlan.maxGoalCarbohydrates,
681
                  onChanged: (value) => widget._plan.goalCarbohydrates = value,
×
682
                ),
683
                DecimalInputWidget(
×
684
                  key: const Key('field-goal-fat'),
685
                  value: widget._plan.goalFat,
×
686
                  labelText: AppLocalizations.of(context).goalFat,
×
687
                  suffixText: AppLocalizations.of(context).g,
×
688
                  min: 0,
689
                  max: NutritionalPlan.maxGoalFat,
690
                  onChanged: (value) => widget._plan.goalFat = value,
×
691
                ),
692
              ],
693
            ),
694

695
          if (_goalType == GoalType.advanced)
2✔
696
            DecimalInputWidget(
×
697
              key: const Key('field-goal-fiber'),
698
              value: widget._plan.goalFiber,
×
699
              labelText: AppLocalizations.of(context).goalFiber,
×
700
              suffixText: AppLocalizations.of(context).g,
×
701
              min: 0,
702
              max: NutritionalPlan.maxGoalFiber,
703
              onChanged: (value) => widget._plan.goalFiber = value,
×
704
            ),
705
          FormSubmitButton(
1✔
706
            key: const Key(SUBMIT_BUTTON_KEY_NAME),
707
            label: AppLocalizations.of(context).save,
2✔
708
            onPressed: () async {
1✔
709
              // Validate and save the current values to the plan
710
              final isValid = _form.currentState!.validate();
3✔
711
              if (!isValid) {
712
                return;
713
              }
714
              _form.currentState!.save();
3✔
715

716
              // Save to DB
717
              final notifier = ref.read(nutritionProvider.notifier);
4✔
718
              if (!isCreating) {
719
                await notifier.editPlan(widget._plan);
×
720
                if (context.mounted) {
×
721
                  Navigator.of(context).pop();
×
722
                }
723
              } else {
724
                final saved = await notifier.addPlan(widget._plan);
3✔
725
                if (context.mounted) {
1✔
726
                  Navigator.of(context).pushReplacementNamed(
2✔
727
                    NutritionalPlanScreen.routeName,
728
                    arguments: saved.id,
1✔
729
                  );
730
                }
731
              }
732
            },
733
          ),
734
        ],
735
      ),
736
    );
737
  }
738
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc