• 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

38.14
/lib/features/measurements/widgets/forms.dart
1
/*
2
 * This file is part of wger Workout Manager <https://github.com/wger-project>.
3
 * Copyright (c)  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/widgets/datetime_input.dart';
22
import 'package:wger/core/widgets/decimal_input.dart';
23
import 'package:wger/core/widgets/error.dart';
24
import 'package:wger/core/widgets/form_submit_button.dart';
25
import 'package:wger/core/widgets/progress_indicator.dart';
26
import 'package:wger/features/measurements/models/measurement_category.dart';
27
import 'package:wger/features/measurements/models/measurement_entry.dart';
28
import 'package:wger/features/measurements/providers/measurement_notifier.dart';
29
import 'package:wger/l10n/generated/app_localizations.dart';
30

31
class MeasurementCategoryForm extends ConsumerStatefulWidget {
32
  final MeasurementCategory? _category;
33

34
  const MeasurementCategoryForm([this._category]);
1✔
35

36
  @override
1✔
37
  ConsumerState<MeasurementCategoryForm> createState() => _MeasurementCategoryFormState();
1✔
38
}
39

40
class _MeasurementCategoryFormState extends ConsumerState<MeasurementCategoryForm> {
41
  final _form = GlobalKey<FormState>();
42

43
  late MeasurementCategory _draft;
44

45
  @override
1✔
46
  void initState() {
47
    super.initState();
1✔
48
    _draft = widget._category ?? MeasurementCategory();
4✔
49
  }
50

51
  @override
1✔
52
  Widget build(BuildContext context) {
53
    // A category with children is a group whatever its metric type says, which
54
    // is also how the charts decide. Both the chart type and the parent are
55
    // meaningless for one
56
    final categories = ref.watch(measurementProvider).asData?.value ?? [];
5✔
57
    final hasChildren = _draft.id != null && categories.any((c) => c.parentId == _draft.id);
2✔
58

59
    // What the chart type picker offers: no override, plus what this metric
60
    // type may be drawn as. Empty for a group, whose chart follows from what
61
    // its components are to each other rather than from a preference
62
    final chartTypes = hasChildren || _draft.metricType.availableChartTypes.isEmpty
4✔
63
        ? const <ChartType>[]
64
        : [ChartType.auto, ..._draft.metricType.availableChartTypes];
4✔
65

66
    // Name and unit belong to the user only for a free-form category. A typed
67
    // one takes both from its metric type, which is also what is shown for it
68
    final isCustom = _draft.metricType == MetricType.custom;
3✔
69

70
    return Form(
1✔
71
      key: _form,
1✔
72
      child: Column(
1✔
73
        children: [
1✔
74
          // Name
75
          if (isCustom)
76
            TextFormField(
1✔
77
              initialValue: _draft.name,
2✔
78
              decoration: InputDecoration(
1✔
79
                labelText: AppLocalizations.of(context).name,
2✔
80
                helperText: AppLocalizations.of(context).measurementCategoriesHelpText,
2✔
81
              ),
82
              maxLength: MeasurementCategory.maxNameChars,
83
              onSaved: (value) => _draft = _draft.copyWith(name: value ?? ''),
×
84
              validator: (value) {
×
85
                final i18n = AppLocalizations.of(context);
×
86
                if (value!.isEmpty) {
×
87
                  return i18n.enterValue;
×
88
                }
89
                if (value.length > MeasurementCategory.maxNameChars) {
×
90
                  return i18n.enterMaxCharacters(MeasurementCategory.maxNameChars.toString());
×
91
                }
92
                return null;
93
              },
94
            ),
95

96
          // Unit
97
          if (isCustom)
98
            TextFormField(
1✔
99
              initialValue: _draft.unit,
2✔
100
              decoration: InputDecoration(
1✔
101
                labelText: AppLocalizations.of(context).unit,
2✔
102
                helperText: AppLocalizations.of(context).measurementEntriesHelpText,
2✔
103
              ),
104
              maxLength: MeasurementCategory.maxUnitChars,
105
              onSaved: (value) => _draft = _draft.copyWith(unit: value ?? ''),
×
106
              validator: (value) {
×
107
                final i18n = AppLocalizations.of(context);
×
108
                if (value!.isEmpty) {
×
109
                  return i18n.enterValue;
×
110
                }
111
                if (value.length > MeasurementCategory.maxUnitChars) {
×
112
                  return i18n.enterMaxCharacters(MeasurementCategory.maxUnitChars.toString());
×
113
                }
114
                return null;
115
              },
116
            ),
117

118
          // The metric type is picked when the category is created (see
119
          // MetricPickerSheet) and fixed from then on: the key of a typed
120
          // category is derived from it, and the server refuses a change
121

122
          // Chart type. Only the shapes that are a matter of taste are offered,
123
          // and only those the metric type can actually be drawn as; a group
124
          // gets none, its chart follows from what its components are
125
          if (chartTypes.isNotEmpty)
1✔
126
            DropdownButtonFormField(
1✔
127
              // A type stored by a client that offers more of them than this
128
              // one falls back to the derived chart, so it shows as automatic
129
              initialValue: chartTypes.contains(_draft.chartType)
3✔
130
                  ? _draft.chartType
2✔
131
                  : ChartType.auto,
132
              decoration: InputDecoration(labelText: AppLocalizations.of(context).chartType),
3✔
133
              items: chartTypes
134
                  .map((t) => DropdownMenuItem(value: t, child: Text(t.localized(context))))
5✔
135
                  .toList(),
1✔
136
              onChanged: (value) {
×
137
                if (value != null) {
138
                  setState(() {
×
139
                    _draft = _draft.copyWith(chartType: value);
×
140
                  });
141
                }
142
              },
143
            ),
144

145
          // Parent group (multi-value measurements, e.g. blood pressure).
146
          // Mirrors the server rules: only top-level, entry-free categories
147
          // can be parents, a category with children cannot be nested, a typed
148
          // category stays top-level, and a group takes only its own
149
          // components (which it is created with).
150
          Builder(
1✔
151
            builder: (context) {
1✔
152
              if (hasChildren || _draft.metricType != MetricType.custom) {
3✔
153
                return const SizedBox.shrink();
154
              }
155

156
              final candidates = categories
157
                  .where(
1✔
158
                    (c) =>
×
159
                        c.parentId == null &&
×
160
                        c.id != _draft.id &&
×
161
                        c.entries.isEmpty &&
×
162
                        !c.isOfficialBodyWeight &&
×
163
                        !c.metricType.isGroup,
×
164
                  )
165
                  .toList();
1✔
166
              if (candidates.isEmpty) {
1✔
167
                return const SizedBox.shrink();
168
              }
169

170
              final initialParent = candidates.any((c) => c.id == _draft.parentId)
×
171
                  ? _draft.parentId
×
172
                  : null;
173

174
              return DropdownButtonFormField<String?>(
×
175
                initialValue: initialParent,
176
                decoration: InputDecoration(
×
177
                  labelText: AppLocalizations.of(context).partOfGroup,
×
178
                ),
179
                items: [
×
180
                  DropdownMenuItem<String?>(
×
181
                    value: null,
182
                    child: Text(AppLocalizations.of(context).noGroup),
×
183
                  ),
184
                  ...candidates.map(
×
185
                    (c) => DropdownMenuItem<String?>(value: c.id, child: Text(c.name)),
×
186
                  ),
187
                ],
188
                onChanged: (value) {
×
189
                  setState(() {
×
190
                    _draft = _draft.copyWith(parentId: value);
×
191
                  });
192
                },
193
              );
194
            },
195
          ),
196
          FormSubmitButton(
1✔
197
            label: AppLocalizations.of(context).save,
2✔
198
            onPressed: () async {
×
199
              if (!_form.currentState!.validate()) {
×
200
                return;
201
              }
202
              _form.currentState!.save();
×
203

204
              final notifier = ref.read(measurementProvider.notifier);
×
205
              if (_draft.id == null) {
×
206
                await notifier.addCategory(_draft);
×
207
              } else {
208
                notifier.updateCategory(_draft);
×
209
              }
210

211
              if (context.mounted) {
×
212
                Navigator.of(context).pop();
×
213
              }
214
            },
215
          ),
216
        ],
217
      ),
218
    );
219
  }
220
}
221

222
class MeasurementEntryForm extends ConsumerStatefulWidget {
223
  final String _categoryId;
224
  final MeasurementEntry? _entry;
225

226
  const MeasurementEntryForm(this._categoryId, [MeasurementEntry? entry]) : _entry = entry;
×
227

228
  @override
×
229
  ConsumerState<MeasurementEntryForm> createState() => _MeasurementEntryFormState();
×
230
}
231

232
class _MeasurementEntryFormState extends ConsumerState<MeasurementEntryForm> {
233
  final _form = GlobalKey<FormState>();
234
  final _notesController = TextEditingController();
235

236
  late final String? _existingId = widget._entry?.id;
237
  late DateTime _date = widget._entry?.date ?? DateTime.now();
238
  num? _value;
239
  String _notes = '';
240

241
  @override
×
242
  void initState() {
243
    super.initState();
×
244
    _value = widget._entry?.value;
×
245
    _notes = widget._entry?.notes ?? '';
×
246
    _notesController.text = _notes;
×
247
  }
248

249
  @override
×
250
  void dispose() {
251
    _notesController.dispose();
×
252
    super.dispose();
×
253
  }
254

255
  @override
×
256
  Widget build(BuildContext context) {
257
    final notifier = ref.read(measurementProvider.notifier);
×
258
    final Future<MeasurementCategory?> categoryFuture = notifier.getCategoryById(
×
259
      widget._categoryId,
×
260
    );
261

262
    return FutureBuilder(
×
263
      future: categoryFuture,
264
      builder: (context, snapshot) {
×
265
        if (snapshot.connectionState == ConnectionState.waiting) {
×
266
          return const BoxedProgressIndicator();
267
        }
268
        if (snapshot.hasError) {
×
269
          return StreamErrorIndicator(snapshot.error.toString());
×
270
        }
271
        if (!snapshot.hasData || snapshot.data == null) {
×
272
          return const Text('Category not found');
273
        }
274

275
        final category = snapshot.data!;
×
276

277
        return Form(
×
278
          key: _form,
×
279
          child: Column(
×
280
            children: [
×
281
              // Date
282
              DateInputWidget(
×
283
                value: _date,
×
284
                labelText: AppLocalizations.of(context).date,
×
285
                firstDate: DateTime(DateTime.now().year - 10),
×
286
                lastDate: DateTime.now(),
×
287
                onChanged: (date) {
×
288
                  _date = _date.copyWith(
×
289
                    year: date.year,
×
290
                    month: date.month,
×
291
                    day: date.day,
×
292
                  );
293
                },
294
              ),
295

296
              // Time
297
              TimeInputWidget(
×
298
                value: TimeOfDay.fromDateTime(_date),
×
299
                labelText: AppLocalizations.of(context).time,
×
300
                onChanged: (time) {
×
301
                  _date = _date.copyWith(
×
302
                    hour: time.hour,
×
303
                    minute: time.minute,
×
304
                    second: 0,
305
                  );
306
                },
307
              ),
308

309
              // Value
310
              DecimalInputWidget(
×
311
                value: _value,
×
312
                labelText: AppLocalizations.of(context).value,
×
313
                suffixText: category.unit,
×
314
                isRequired: true,
315
                min: category.metricType.limits(category.unit).min,
×
316
                max: category.metricType.limits(category.unit).max,
×
317
                onChanged: (value) => _value = value,
×
318
              ),
319
              // Notes
320
              TextFormField(
×
321
                decoration: InputDecoration(labelText: AppLocalizations.of(context).notes),
×
322
                controller: _notesController,
×
323
                onSaved: (newValue) {
×
324
                  _notes = newValue ?? '';
×
325
                },
326
                validator: (value) {
×
327
                  const minLength = 0;
328
                  const maxLength = 100;
329
                  if (value!.isNotEmpty && (value.length < minLength || value.length > maxLength)) {
×
330
                    return AppLocalizations.of(context).enterCharacters(
×
331
                      minLength.toString(),
×
332
                      maxLength.toString(),
×
333
                    );
334
                  }
335
                  return null;
336
                },
337
              ),
338

339
              FormSubmitButton(
×
340
                label: AppLocalizations.of(context).save,
×
341
                onPressed: () async {
×
342
                  final isValid = _form.currentState!.validate();
×
343
                  if (!isValid) {
344
                    return;
345
                  }
346
                  _form.currentState!.save();
×
347

348
                  // Source, external id and extra data are not editable; keep
349
                  // the existing values so edits to imported entries stay
350
                  // deduplicable and the entered unit survives
351
                  final entry = MeasurementEntry(
×
352
                    id: _existingId,
×
353
                    categoryId: category.id!,
×
354
                    date: _date,
×
355
                    value: _value!,
×
356
                    notes: _notes,
×
357
                    source: widget._entry?.source ?? 'user',
×
358
                    externalId: widget._entry?.externalId,
×
359
                    extraData: widget._entry?.extraData,
×
360
                  );
361
                  if (entry.id == null) {
×
362
                    await notifier.addEntry(entry);
×
363
                  } else {
364
                    await notifier.updateEntry(entry);
×
365
                  }
366

367
                  if (context.mounted) {
×
368
                    Navigator.of(context).pop();
×
369
                  }
370
                },
371
              ),
372
            ],
373
          ),
374
        );
375
      },
376
    );
377
  }
378
}
379

380
/// Entry form for a multi-value group (e.g. blood pressure): one value field
381
/// per component, saved as one entry per component with a shared timestamp.
382
class GroupMeasurementEntryForm extends ConsumerStatefulWidget {
383
  final MeasurementCategory _group;
384

385
  const GroupMeasurementEntryForm(this._group);
1✔
386

387
  @override
1✔
388
  ConsumerState<GroupMeasurementEntryForm> createState() => _GroupMeasurementEntryFormState();
1✔
389
}
390

391
class _GroupMeasurementEntryFormState extends ConsumerState<GroupMeasurementEntryForm> {
392
  final _form = GlobalKey<FormState>();
393

394
  DateTime _date = DateTime.now();
395
  late final Map<String, num?> _values = {
2✔
396
    for (final child in widget._group.children) child.id!: null,
5✔
397
  };
398

399
  @override
1✔
400
  Widget build(BuildContext context) {
401
    return Form(
1✔
402
      key: _form,
1✔
403
      child: Column(
1✔
404
        children: [
1✔
405
          // Date and time are shared by all components of the reading
406
          DateInputWidget(
1✔
407
            value: _date,
1✔
408
            labelText: AppLocalizations.of(context).date,
2✔
409
            firstDate: DateTime(DateTime.now().year - 10),
4✔
410
            lastDate: DateTime.now(),
1✔
411
            onChanged: (date) {
×
412
              _date = _date.copyWith(
×
413
                year: date.year,
×
414
                month: date.month,
×
415
                day: date.day,
×
416
              );
417
            },
418
          ),
419
          TimeInputWidget(
1✔
420
            value: TimeOfDay.fromDateTime(_date),
2✔
421
            labelText: AppLocalizations.of(context).time,
2✔
422
            onChanged: (time) {
×
423
              _date = _date.copyWith(
×
424
                hour: time.hour,
×
425
                minute: time.minute,
×
426
                second: 0,
427
              );
428
            },
429
          ),
430

431
          // One value field per component, each bounded by its own type:
432
          // systolic and diastolic do not share a range
433
          for (final child in widget._group.children)
3✔
434
            DecimalInputWidget(
1✔
435
              value: _values[child.id],
3✔
436
              labelText: child.displayName(context),
1✔
437
              suffixText: child.unit.isNotEmpty ? child.unit : widget._group.unit,
3✔
438
              isRequired: true,
439
              min: child.metricType.limits(child.unit).min,
4✔
440
              max: child.metricType.limits(child.unit).max,
4✔
441
              onChanged: (value) => _values[child.id!] = value,
4✔
442
            ),
443

444
          FormSubmitButton(
1✔
445
            label: AppLocalizations.of(context).save,
2✔
446
            onPressed: () async {
1✔
447
              if (!_form.currentState!.validate()) {
3✔
448
                return;
449
              }
450
              _form.currentState!.save();
3✔
451

452
              final entries = widget._group.children
3✔
453
                  .map(
1✔
454
                    (child) => MeasurementEntry(
2✔
455
                      categoryId: child.id!,
1✔
456
                      date: _date,
1✔
457
                      value: _values[child.id]!,
3✔
458
                      notes: '',
459
                    ),
460
                  )
461
                  .toList();
1✔
462
              await ref.read(measurementProvider.notifier).addGroupEntries(entries);
5✔
463

464
              if (context.mounted) {
1✔
465
                Navigator.of(context).pop();
2✔
466
              }
467
            },
468
          ),
469
        ],
470
      ),
471
    );
472
  }
473
}
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