From 7001bca7396a461367eb930fe406117a24ccfcff Mon Sep 17 00:00:00 2001 From: pankaj-basnet <165250380+pankaj-basnet@users.noreply.github.com> Date: Sat, 16 May 2026 16:18:28 +0545 Subject: [PATCH 1/2] feat(measurements): implement measurement groups, dynamic formula evaluation --- .../measurements/measurement_category.dart | 27 +- .../measurements/measurement_category.g.dart | 10 + .../measurements/measurement_group.dart | 37 ++ .../measurements/measurement_group.g.dart | 25 ++ lib/providers/measurement.dart | 117 ++++- .../measurement_categories_screen.dart | 47 +- lib/screens/measurement_entries_screen.dart | 30 +- lib/widgets/measurements/categories.dart | 12 +- lib/widgets/measurements/categories_card.dart | 59 ++- lib/widgets/measurements/entries.dart | 4 +- lib/widgets/measurements/forms.dart | 418 ++++++++++++------ 11 files changed, 620 insertions(+), 166 deletions(-) create mode 100644 lib/models/measurements/measurement_group.dart create mode 100644 lib/models/measurements/measurement_group.g.dart diff --git a/lib/models/measurements/measurement_category.dart b/lib/models/measurements/measurement_category.dart index 05d6ade02..eb268b0e5 100644 --- a/lib/models/measurements/measurement_category.dart +++ b/lib/models/measurements/measurement_category.dart @@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:wger/core/exceptions/no_such_entry_exception.dart'; import 'package:wger/models/measurements/measurement_entry.dart'; +import 'package:wger/models/measurements/measurement_group.dart'; part 'measurement_category.g.dart'; @@ -16,6 +17,18 @@ class MeasurementCategory extends Equatable { @JsonKey(required: true) final String unit; + @JsonKey(name: 'group', includeToJson: true) + final int? groupId; + + @JsonKey(name: 'group_detail', includeToJson: false) + final MeasurementGroup? groupDetail; + + @JsonKey(name: 'formula', includeToJson: true) + final String? formula; + + @JsonKey(name: 'is_dynamic', includeToJson: false, defaultValue: false) + final bool isDynamic; + @JsonKey(defaultValue: [], toJson: _nullValue) final List entries; @@ -23,6 +36,10 @@ class MeasurementCategory extends Equatable { required this.id, required this.name, required this.unit, + this.groupId, + this.groupDetail, + this.formula, + this.isDynamic = false, this.entries = const [], }); @@ -30,12 +47,20 @@ class MeasurementCategory extends Equatable { int? id, String? name, String? unit, + int? groupId, + MeasurementGroup? groupDetail, + String? formula, + bool? isDynamic, List? entries, }) { return MeasurementCategory( id: id ?? this.id, name: name ?? this.name, unit: unit ?? this.unit, + groupId: groupId ?? this.groupId, + groupDetail: groupDetail ?? this.groupDetail, + formula: formula ?? this.formula, + isDynamic: isDynamic ?? this.isDynamic, entries: entries ?? this.entries, ); } @@ -54,7 +79,7 @@ class MeasurementCategory extends Equatable { Map toJson() => _$MeasurementCategoryToJson(this); @override - List get props => [id, name, unit, entries]; + List get props => [id, name, unit, groupId, formula, isDynamic, entries]; // Helper function which makes the entries list of the toJson output null, as it isn't needed static Null _nullValue(List _) => null; diff --git a/lib/models/measurements/measurement_category.g.dart b/lib/models/measurements/measurement_category.g.dart index e971c3884..8ead8e403 100644 --- a/lib/models/measurements/measurement_category.g.dart +++ b/lib/models/measurements/measurement_category.g.dart @@ -12,6 +12,14 @@ MeasurementCategory _$MeasurementCategoryFromJson(Map json) { id: (json['id'] as num?)?.toInt(), name: json['name'] as String, unit: json['unit'] as String, + groupId: (json['group'] as num?)?.toInt(), + groupDetail: json['group_detail'] == null + ? null + : MeasurementGroup.fromJson( + json['group_detail'] as Map, + ), + formula: json['formula'] as String?, + isDynamic: json['is_dynamic'] as bool? ?? false, entries: (json['entries'] as List?) ?.map((e) => MeasurementEntry.fromJson(e as Map)) @@ -26,5 +34,7 @@ Map _$MeasurementCategoryToJson( 'id': instance.id, 'name': instance.name, 'unit': instance.unit, + 'group': instance.groupId, + 'formula': instance.formula, 'entries': MeasurementCategory._nullValue(instance.entries), }; diff --git a/lib/models/measurements/measurement_group.dart b/lib/models/measurements/measurement_group.dart new file mode 100644 index 000000000..93fd211e5 --- /dev/null +++ b/lib/models/measurements/measurement_group.dart @@ -0,0 +1,37 @@ + +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'measurement_group.g.dart'; + +/// A named group linking related measurement categories together. +/// Example: "Blood Pressure" groups Systolic + Diastolic categories. +@JsonSerializable() +class MeasurementGroup extends Equatable { + @JsonKey(required: true) + final int id; + + @JsonKey(required: true) + final String uuid; + + @JsonKey(required: true) + final String name; + + @JsonKey(defaultValue: '') + final String description; + + const MeasurementGroup({ + required this.id, + required this.uuid, + required this.name, + this.description = '', + }); + + factory MeasurementGroup.fromJson(Map json) => + _$MeasurementGroupFromJson(json); + + Map toJson() => _$MeasurementGroupToJson(this); + + @override + List get props => [id, uuid, name, description]; +} \ No newline at end of file diff --git a/lib/models/measurements/measurement_group.g.dart b/lib/models/measurements/measurement_group.g.dart new file mode 100644 index 000000000..51fee1ae7 --- /dev/null +++ b/lib/models/measurements/measurement_group.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'measurement_group.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +MeasurementGroup _$MeasurementGroupFromJson(Map json) { + $checkKeys(json, requiredKeys: const ['id', 'uuid', 'name']); + return MeasurementGroup( + id: (json['id'] as num).toInt(), + uuid: json['uuid'] as String, + name: json['name'] as String, + description: json['description'] as String? ?? '', + ); +} + +Map _$MeasurementGroupToJson(MeasurementGroup instance) => + { + 'id': instance.id, + 'uuid': instance.uuid, + 'name': instance.name, + 'description': instance.description, + }; diff --git a/lib/providers/measurement.dart b/lib/providers/measurement.dart index 6c0292f0d..213967177 100644 --- a/lib/providers/measurement.dart +++ b/lib/providers/measurement.dart @@ -23,6 +23,7 @@ import 'package:wger/core/exceptions/no_such_entry_exception.dart'; import 'package:wger/helpers/consts.dart'; import 'package:wger/models/measurements/measurement_category.dart'; import 'package:wger/models/measurements/measurement_entry.dart'; +import 'package:wger/models/measurements/measurement_group.dart'; import 'package:wger/providers/base_provider.dart'; class MeasurementProvider with ChangeNotifier { @@ -30,18 +31,22 @@ class MeasurementProvider with ChangeNotifier { static const _categoryUrl = 'measurement-category'; static const _entryUrl = 'measurement'; - + static const _groupUrl = 'measurement-group'; + static const _dynamicValuesAction = 'dynamic-values'; final WgerBaseProvider baseProvider; List _categories = []; + List _groups = []; MeasurementProvider(this.baseProvider); List get categories => _categories; + List get groups => _groups; /// Clears all lists void clear() { _categories = []; + _groups = []; } /// Finds the category by ID @@ -69,6 +74,12 @@ class MeasurementProvider with ChangeNotifier { /// Fetches and sets the measurement entries for the given category Future fetchAndSetCategoryEntries(int id) async { final category = findCategoryById(id); + + if (category.isDynamic) { + await fetchDynamicCategoryEntries(id); + return; + } + final categoryIndex = _categories.indexOf(category); // Process the response @@ -87,10 +98,39 @@ class MeasurementProvider with ChangeNotifier { notifyListeners(); } + /// Fetches computed values for a dynamic (formula-based) category + /// and sets the measurement entries + Future fetchDynamicCategoryEntries(int categoryId) async { + final category = findCategoryById(categoryId); + if (!category.isDynamic) return; + + final categoryIndex = _categories.indexOf(category); + + final requestUrl = baseProvider.makeUrl( + '$_categoryUrl/$categoryId/$_dynamicValuesAction', + ); + + // The dynamic-values endpoint returns a plain list, not paginated. + final dynamic rawData = await baseProvider.fetch(requestUrl); + final List data = rawData is List ? rawData : (rawData['results'] ?? []); + + final List computedEntries = data + .map((e) => MeasurementEntry.fromJson(e as Map)) + .toList(); + + final MeasurementCategory editedCategory = category.copyWith( + entries: computedEntries, + ); + _categories.removeAt(categoryIndex); + _categories.insert(categoryIndex, editedCategory); + notifyListeners(); + } + /// Fetches and sets the measurement categories and their entries Future fetchAndSetAllCategoriesAndEntries() async { _logger.info('Fetching all measurement categories and entries'); + await fetchAndSetGroups(); await fetchAndSetCategories(); await Future.wait(_categories.map((e) => fetchAndSetCategoryEntries(e.id!)).toList()); } @@ -124,7 +164,15 @@ class MeasurementProvider with ChangeNotifier { /// Edits a measurement category /// Currently there isn't any fallback if the call to the api is unsuccessful, as WgerBaseProvider.patch only returns the response body and not the whole response - Future editCategory(int id, String? newName, String? newUnit) async { + Future editCategory( + int id, + String? newName, + String? newUnit, + int? newGroupId, + String? newFormula, { + bool clearGroup = false, + bool clearFormula = false, + }) async { final MeasurementCategory oldCategory = findCategoryById(id); final int categoryIndex = _categories.indexOf(oldCategory); final MeasurementCategory tempNewCategory = oldCategory.copyWith(name: newName, unit: newUnit); @@ -141,6 +189,71 @@ class MeasurementProvider with ChangeNotifier { notifyListeners(); } + // --- Measurement Groups --- + + /// Finds a group by ID + MeasurementGroup findGroupById(int id) { + return _groups.firstWhere( + (group) => group.id == id, + orElse: () => throw const NoSuchEntryException(), + ); + } + + /// Fetches and sets all measurement groups from the server. + Future fetchAndSetGroups() async { + final requestUrl = baseProvider.makeUrl(_groupUrl, query: {'limit': API_MAX_PAGE_SIZE}); + final data = await baseProvider.fetchPaginated(requestUrl); + _groups = data.map((e) => MeasurementGroup.fromJson(e)).toList(); + notifyListeners(); + } + + /// Adds a measurement group. + Future addGroup(MeasurementGroup group) async { + final Uri postUri = baseProvider.makeUrl(_groupUrl); + final Map newGroupMap = await baseProvider.post(group.toJson(), postUri); + final MeasurementGroup newGroup = MeasurementGroup.fromJson(newGroupMap); + _groups.add(newGroup); + _groups.sort((a, b) => a.name.compareTo(b.name)); + notifyListeners(); + } + + /// Edits a measurement group name/description. + Future editGroup(int id, String? newName, String? newDescription) async { + final MeasurementGroup oldGroup = findGroupById(id); + final int groupIndex = _groups.indexOf(oldGroup); + final MeasurementGroup tempNew = MeasurementGroup( + id: oldGroup.id, + uuid: oldGroup.uuid, + name: newName ?? oldGroup.name, + description: newDescription ?? oldGroup.description, + ); + final Map response = await baseProvider.patch( + tempNew.toJson(), + baseProvider.makeUrl(_groupUrl, id: id), + ); + final MeasurementGroup newGroup = MeasurementGroup.fromJson(response); + _groups.removeAt(groupIndex); + _groups.insert(groupIndex, newGroup); + notifyListeners(); + } + + /// Deletes a measurement group. + Future deleteGroup(int id) async { + final MeasurementGroup group = findGroupById(id); + final int groupIndex = _groups.indexOf(group); + _groups.remove(group); + notifyListeners(); + try { + await baseProvider.deleteRequest(_groupUrl, id); + } on WgerHttpException { + _groups.insert(groupIndex, group); + notifyListeners(); + rethrow; + } + } + + // --- Measurement Entries --- + /// Adds a measurement entry Future addEntry(MeasurementEntry entry) async { final Uri postUri = baseProvider.makeUrl(_entryUrl); diff --git a/lib/screens/measurement_categories_screen.dart b/lib/screens/measurement_categories_screen.dart index 6feffb47a..5aa28a2a2 100644 --- a/lib/screens/measurement_categories_screen.dart +++ b/lib/screens/measurement_categories_screen.dart @@ -25,11 +25,19 @@ import 'package:wger/screens/form_screen.dart'; import 'package:wger/widgets/measurements/categories.dart'; import 'package:wger/widgets/measurements/forms.dart'; -class MeasurementCategoriesScreen extends StatelessWidget { +class MeasurementCategoriesScreen extends StatefulWidget { + const MeasurementCategoriesScreen(); static const routeName = '/measurement-categories'; + @override + State createState() => _MeasurementCategoriesScreenState(); +} + +class _MeasurementCategoriesScreenState extends State { + int? _selectedGroupId; + @override Widget build(BuildContext context) { return Scaffold( @@ -49,7 +57,42 @@ class MeasurementCategoriesScreen extends StatelessWidget { ), body: WidescreenWrapper( child: Consumer( - builder: (context, provider, child) => const CategoriesList(), + builder: (context, provider, child) { + + final groups = provider.groups; + if (groups.isEmpty) return const SizedBox.shrink(); + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + children: [ + // clears group filter + FilterChip( + label: const Text('All'), + selected: _selectedGroupId == null, + onSelected: (_) => setState(() => _selectedGroupId = null), + ), + const SizedBox(width: 6), + // One chip per group + ...groups.map( + (group) => Padding( + padding: const EdgeInsets.only(right: 6), + child: FilterChip( + label: Text(group.name), + selected: _selectedGroupId == group.id, + onSelected: (_) => setState( + () => _selectedGroupId = + _selectedGroupId == group.id ? null : group.id, + ), + ), + ), + ), + CategoriesList(_selectedGroupId), + ], + ), + ); + } ), ), ); diff --git a/lib/screens/measurement_entries_screen.dart b/lib/screens/measurement_entries_screen.dart index 3f30ed3e3..b8a5728af 100644 --- a/lib/screens/measurement_entries_screen.dart +++ b/lib/screens/measurement_entries_screen.dart @@ -54,6 +54,32 @@ class MeasurementEntriesScreen extends StatelessWidget { return const SizedBox(); // Return empty widget until pop happens } + if (category.isDynamic) { + return Container( + margin: const EdgeInsets.all(8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + const Icon(Icons.auto_graph, color: Colors.blue, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Values for this category are calculated automatically ' + 'using the "${category.formula}" formula. ' + 'You cannot add entries manually.', + style: const TextStyle(fontSize: 13), + ), + ), + ], + ), + ); + } + return Scaffold( appBar: AppBar( title: Text(category.name), @@ -68,7 +94,7 @@ class MeasurementEntriesScreen extends StatelessWidget { FormScreen.routeName, arguments: FormScreenArguments( AppLocalizations.of(context).edit, - MeasurementCategoryForm(category), + MeasurementCategoryForm(), ), ); break; @@ -145,7 +171,7 @@ class MeasurementEntriesScreen extends StatelessWidget { FormScreen.routeName, arguments: FormScreenArguments( AppLocalizations.of(context).newEntry, - MeasurementEntryForm(categoryId), + MeasurementEntryForm(categoryId: categoryId), ), ); }, diff --git a/lib/widgets/measurements/categories.dart b/lib/widgets/measurements/categories.dart index 32abf49e6..a00905175 100644 --- a/lib/widgets/measurements/categories.dart +++ b/lib/widgets/measurements/categories.dart @@ -23,17 +23,23 @@ import 'package:wger/providers/measurement.dart'; import 'categories_card.dart'; class CategoriesList extends StatelessWidget { - const CategoriesList(); + final int? _selectedGrouptID; + const CategoriesList(this._selectedGrouptID); + @override Widget build(BuildContext context) { final provider = Provider.of(context, listen: false); + final categories = provider.categories.where((cat) { + if (_selectedGrouptID == null) return true; + return cat.groupId == _selectedGrouptID; + }).toList(); return RefreshIndicator( onRefresh: () => provider.fetchAndSetAllCategoriesAndEntries(), child: ListView.builder( padding: const EdgeInsets.all(10.0), - itemCount: provider.categories.length, - itemBuilder: (context, index) => CategoriesCard(provider.categories[index]), + itemCount: categories.length, + itemBuilder: (context, index) => CategoriesCard(categories[index]), ), ); } diff --git a/lib/widgets/measurements/categories_card.dart b/lib/widgets/measurements/categories_card.dart index 27ef09df2..c31401eb4 100644 --- a/lib/widgets/measurements/categories_card.dart +++ b/lib/widgets/measurements/categories_card.dart @@ -33,6 +33,35 @@ class CategoriesCard extends StatelessWidget { style: Theme.of(context).textTheme.titleLarge, ), ), + // Group + if (currentCategory.groupDetail != null) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Chip( + avatar: const Icon(Icons.link, size: 14), + label: Text( + currentCategory.groupDetail!.name, + style: const TextStyle(fontSize: 11), + ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + ), + ), + // Dynamic + if (currentCategory.isDynamic) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Chip( + avatar: const Icon(Icons.auto_graph, size: 14, color: Colors.blue), + label: const Text( + 'Auto-calculated', + style: TextStyle(fontSize: 11, color: Colors.blue), + ), + backgroundColor: Colors.blue.withValues(alpha: 0.1), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + ), + ), Container( padding: const EdgeInsets.all(10), height: 220, @@ -68,19 +97,23 @@ class CategoriesCard extends StatelessWidget { ); }, ), - IconButton( - onPressed: () async { - await Navigator.pushNamed( - context, - FormScreen.routeName, - arguments: FormScreenArguments( - AppLocalizations.of(context).newEntry, - MeasurementEntryForm(currentCategory.id!), - ), - ); - }, - icon: const Icon(Icons.add), - ), + // hide add-entry button for dynamic categories + if (!currentCategory.isDynamic) + IconButton( + onPressed: () async { + await Navigator.pushNamed( + context, + FormScreen.routeName, + arguments: FormScreenArguments( + AppLocalizations.of(context).newEntry, + MeasurementEntryForm( + categoryId: currentCategory.id!, + ), + ), + ); + }, + icon: const Icon(Icons.add), + ), ], ), ), diff --git a/lib/widgets/measurements/entries.dart b/lib/widgets/measurements/entries.dart index 10220dbe9..6985a1bc4 100644 --- a/lib/widgets/measurements/entries.dart +++ b/lib/widgets/measurements/entries.dart @@ -80,8 +80,8 @@ class EntriesList extends StatelessWidget { arguments: FormScreenArguments( AppLocalizations.of(context).edit, MeasurementEntryForm( - currentEntry.category, - currentEntry, + categoryId: currentEntry.category, + entry: currentEntry, ), ), ), diff --git a/lib/widgets/measurements/forms.dart b/lib/widgets/measurements/forms.dart index 38734d2ac..b69f08015 100644 --- a/lib/widgets/measurements/forms.dart +++ b/lib/widgets/measurements/forms.dart @@ -23,33 +23,66 @@ import 'package:wger/helpers/consts.dart'; import 'package:wger/l10n/generated/app_localizations.dart'; import 'package:wger/models/measurements/measurement_category.dart'; import 'package:wger/models/measurements/measurement_entry.dart'; +import 'package:wger/models/measurements/measurement_group.dart'; import 'package:wger/providers/measurement.dart'; -class MeasurementCategoryForm extends StatelessWidget { +class MeasurementCategoryForm extends StatefulWidget { + final MeasurementCategory? category; + + const MeasurementCategoryForm({ + super.key, + this.category, + }); + + @override + State createState() => _MeasurementCategoryFormState(); +} + +class _MeasurementCategoryFormState extends State { + final List _dummyGroups = [ + const MeasurementGroup(uuid: '1', id: 1, name: 'Dummy: Bodyweight'), + const MeasurementGroup(uuid: '2', id: 2, name: 'Dummy: Strength'), + ]; + final _form = GlobalKey(); - final nameController = TextEditingController(); - final unitController = TextEditingController(); + late final TextEditingController nameController; + late final TextEditingController unitController; + + int? _categoryId; + + int? _selectedGroupId; + String? _selectedFormula; + bool _isSubmitting = false; - final Map categoryData = { - 'id': null, - 'name': '', - 'unit': '', + static const Map _formulaLabels = { + 'bmi': 'Body Mass Index (BMI)', + 'lbm': 'Lean Body Mass', + '1rm_epley': '1RM — Epley formula', }; - MeasurementCategoryForm([MeasurementCategory? category]) { - //this._category = category ?? MeasurementCategory(); - if (category != null) { - categoryData['id'] = category.id; - categoryData['unit'] = category.unit; - categoryData['name'] = category.name; - } + @override + void initState() { + super.initState(); + final cat = widget.category; + _categoryId = cat?.id; + nameController = TextEditingController(text: cat?.name ?? ''); + unitController = TextEditingController(text: cat?.unit ?? ''); - unitController.text = categoryData['unit']!; - nameController.text = categoryData['name']!; + _selectedGroupId = cat?.groupId; + _selectedFormula = cat?.formula; + } + + @override + void dispose() { + nameController.dispose(); + unitController.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { + final i18n = AppLocalizations.of(context); + final groups = Provider.of(context, listen: false).groups; return Form( key: _form, child: Column( @@ -57,16 +90,13 @@ class MeasurementCategoryForm extends StatelessWidget { // Name TextFormField( decoration: InputDecoration( - labelText: AppLocalizations.of(context).name, - helperText: AppLocalizations.of(context).measurementCategoriesHelpText, + labelText: i18n.name, + helperText: i18n.measurementCategoriesHelpText, ), controller: nameController, - onSaved: (newValue) { - categoryData['name'] = newValue; - }, validator: (value) { - if (value!.isEmpty) { - return AppLocalizations.of(context).enterValue; + if (value == null || value.trim().isEmpty) { + return i18n.enterValue; } return null; }, @@ -75,53 +105,106 @@ class MeasurementCategoryForm extends StatelessWidget { // Unit TextFormField( decoration: InputDecoration( - labelText: AppLocalizations.of(context).unit, - helperText: AppLocalizations.of(context).measurementEntriesHelpText, + labelText: i18n.unit, + helperText: i18n.measurementEntriesHelpText, ), controller: unitController, - onSaved: (newValue) { - categoryData['unit'] = newValue; - }, validator: (value) { - if (value!.isEmpty) { - return AppLocalizations.of(context).enterValue; + if (value == null || value.trim().isEmpty) { + return i18n.enterValue; } return null; }, ), + + // Group dropdown + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _selectedGroupId, + decoration: const InputDecoration( + labelText: 'Group (optional)', + helperText: 'Link this category to a group e.g. "Blood Pressure"', + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('- No group -'), + ), + ...groups.map( + (g) => DropdownMenuItem( + value: g.id, + child: Text(g.name), + ), + ), + ], + onChanged: (val) => setState(() => _selectedGroupId = val), + ), + + // Formula dropdown + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _selectedFormula, + decoration: const InputDecoration( + labelText: 'Auto-calculate from formula (optional)', + helperText: 'values are computed automatically, no entry needed.', + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('- Manual entry -'), + ), + ..._formulaLabels.entries.map( + (e) => DropdownMenuItem( + value: e.key, + child: Text(e.value), + ), + ), + ], + onChanged: (val) => setState(() => _selectedFormula = val), + ), + + const SizedBox(height: 16), ElevatedButton( - child: Text(AppLocalizations.of(context).save), + child: Text(i18n.save), onPressed: () async { // Validate and save the current values to the weightEntry - final isValid = _form.currentState!.validate(); + final isValid = _form.currentState?.validate() ?? false; if (!isValid) { return; } _form.currentState!.save(); + setState(() => _isSubmitting = true); + final measurementProvider = Provider.of(context, listen: false); // Save the entry on the server - categoryData['id'] == null - ? await Provider.of( - context, - listen: false, - ).addCategory( - MeasurementCategory( - id: categoryData['id'], - name: categoryData['name'], - unit: categoryData['unit'], - ), - ) - : await Provider.of( - context, - listen: false, - ).editCategory( - categoryData['id'], - categoryData['name'], - categoryData['unit'], - ); - - if (context.mounted) { - Navigator.of(context).pop(); + try { + if (_categoryId == null) { + await measurementProvider.addCategory( + MeasurementCategory( + id: null, + name: nameController.text.trim(), + unit: unitController.text.trim(), + groupId: _selectedGroupId, + formula: _selectedFormula, + ), + ); + } else { + await measurementProvider.editCategory( + _categoryId!, + nameController.text.trim(), + unitController.text.trim(), + _selectedGroupId, + _selectedFormula, + clearGroup: _selectedGroupId == null, + clearFormula: _selectedFormula == null, + ); + } + + if (context.mounted) { + Navigator.of(context).pop(); + } + } catch (e) { + setState(() => _isSubmitting = false); } }, ), @@ -131,41 +214,62 @@ class MeasurementCategoryForm extends StatelessWidget { } } -class MeasurementEntryForm extends StatelessWidget { +class MeasurementEntryForm extends StatefulWidget { + final int categoryId; + final MeasurementEntry? entry; + + const MeasurementEntryForm({ + super.key, + required this.categoryId, + this.entry, + }); + + @override + State createState() => _MeasurementEntryFormState(); +} + +class _MeasurementEntryFormState extends State { final _form = GlobalKey(); - final int _categoryId; - final _valueController = TextEditingController(); - final _dateController = TextEditingController(text: ''); - final _timeController = TextEditingController(text: ''); - final _notesController = TextEditingController(); - - late final Map _entryData; - - MeasurementEntryForm(this._categoryId, [MeasurementEntry? entry]) { - _entryData = { - 'id': null, - 'category': _categoryId, - 'date': DateTime.now(), - 'value': '', - 'notes': '', - }; - - if (entry != null) { - _entryData['id'] = entry.id; - _entryData['category'] = entry.category; - _entryData['value'] = entry.value; - _entryData['date'] = entry.date; - _entryData['notes'] = entry.notes; - } + late final int _categoryId; + late final TextEditingController _valueController; + late final TextEditingController _dateController; + late final TextEditingController _timeController; + late final TextEditingController _notesController; - _valueController.text = ''; - _notesController.text = _entryData['notes']!; + late DateTime _selectedDateTime; + late num _selectedValue; + late String _selectedNotes; + bool _isSubmitting = false; + + @override + void initState() { + super.initState(); + _categoryId = widget.categoryId; + _selectedDateTime = widget.entry?.date ?? DateTime.now(); + _selectedValue = widget.entry?.value ?? 0; + _selectedNotes = widget.entry?.notes ?? ''; + _dateController = TextEditingController(); + _timeController = TextEditingController(); + _valueController = TextEditingController(); + _notesController = TextEditingController(text: widget.entry?.notes ?? ''); + } + + @override + void dispose() { + _valueController.dispose(); + _dateController.dispose(); + _timeController.dispose(); + _notesController.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { - final dateFormat = DateFormat.yMd(Localizations.localeOf(context).languageCode); - final timeFormat = DateFormat.Hm(Localizations.localeOf(context).languageCode); + final i18n = AppLocalizations.of(context); + final locale = Localizations.localeOf(context).toString(); + + final dateFormat = DateFormat.yMd(locale); + final timeFormat = DateFormat.Hm(locale); final measurementProvider = Provider.of(context, listen: false); final measurementCategory = measurementProvider.categories.firstWhere( @@ -173,17 +277,45 @@ class MeasurementEntryForm extends StatelessWidget { ); if (_dateController.text.isEmpty) { - _dateController.text = dateFormat.format(_entryData['date']); + _dateController.text = dateFormat.format(_selectedDateTime); } if (_timeController.text.isEmpty) { - _timeController.text = timeFormat.format(_entryData['date']); + _timeController.text = timeFormat.format(_selectedDateTime); } - final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString()); + final numberFormat = NumberFormat.decimalPattern(locale); // If the value is not empty, format it - if (_valueController.text.isEmpty && _entryData['value'] != null && _entryData['value'] != '') { - _valueController.text = numberFormat.format(_entryData['value']); + if (_valueController.text.isEmpty && widget.entry?.value != null) { + _valueController.text = numberFormat.format(widget.entry!.value); + } + + if (measurementCategory.isDynamic) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.auto_graph, size: 48, color: Colors.blue), + const SizedBox(height: 12), + Text( + 'This measurement is calculated automatically.', // TODO: i18n + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Text( + 'Formula: ${measurementCategory.formula ?? ''}', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 8), + Text( + 'To see computed values, open the category detail page.', + textAlign: TextAlign.center, + ), + ], + ), + ); } return Form( @@ -193,7 +325,7 @@ class MeasurementEntryForm extends StatelessWidget { // Date TextFormField( decoration: InputDecoration( - labelText: AppLocalizations.of(context).date, + labelText: i18n.date, suffixIcon: const Icon( Icons.calendar_today, key: Key('calendarIcon'), @@ -209,7 +341,7 @@ class MeasurementEntryForm extends StatelessWidget { // Show Date Picker Here final pickedDate = await showDatePicker( context: context, - initialDate: _entryData['date'], + initialDate: _selectedDateTime, firstDate: DateTime(DateTime.now().year - 10), lastDate: DateTime.now(), ); @@ -220,15 +352,15 @@ class MeasurementEntryForm extends StatelessWidget { }, onSaved: (newValue) { final date = dateFormat.parse(newValue!); - _entryData['date'] = (_entryData['date'] as DateTime).copyWith( + _selectedDateTime = (_selectedDateTime as DateTime).copyWith( year: date.year, month: date.month, day: date.day, ); }, validator: (value) { - if (value!.isEmpty) { - return AppLocalizations.of(context).enterValue; + if (value == null || value.isEmpty) { + return i18n.enterValue; } return null; }, @@ -237,7 +369,7 @@ class MeasurementEntryForm extends StatelessWidget { // Time TextFormField( decoration: InputDecoration( - labelText: AppLocalizations.of(context).time, + labelText: i18n.time, suffixIcon: const Icon( Icons.access_time_outlined, key: Key('clockIcon'), @@ -248,7 +380,7 @@ class MeasurementEntryForm extends StatelessWidget { onTap: () async { final pickedTime = await showTimePicker( context: context, - initialTime: TimeOfDay.fromDateTime(_entryData['date']), + initialTime: TimeOfDay.fromDateTime(_selectedDateTime), ); if (pickedTime != null) { @@ -266,7 +398,7 @@ class MeasurementEntryForm extends StatelessWidget { }, onSaved: (newValue) { final time = timeFormat.parse(newValue!); - _entryData['date'] = (_entryData['date'] as DateTime).copyWith( + _selectedDateTime = (_selectedDateTime as DateTime).copyWith( hour: time.hour, minute: time.minute, second: time.second, @@ -277,39 +409,39 @@ class MeasurementEntryForm extends StatelessWidget { // Value TextFormField( decoration: InputDecoration( - labelText: AppLocalizations.of(context).value, + labelText: i18n.value, suffixIcon: Text(measurementCategory.unit), suffixIconConstraints: const BoxConstraints(minWidth: 0, minHeight: 0), ), controller: _valueController, keyboardType: textInputTypeDecimal, validator: (value) { - if (value!.isEmpty) { - return AppLocalizations.of(context).enterValue; + if (value == null || value.isEmpty) { + return i18n.enterValue; } try { numberFormat.parse(value); } catch (error) { - return AppLocalizations.of(context).enterValidNumber; + return i18n.enterValidNumber; } return null; }, onSaved: (newValue) { - _entryData['value'] = numberFormat.parse(newValue!); + _selectedValue = numberFormat.parse(newValue!); }, ), // Notes TextFormField( - decoration: InputDecoration(labelText: AppLocalizations.of(context).notes), + decoration: InputDecoration(labelText: i18n.notes), controller: _notesController, onSaved: (newValue) { - _entryData['notes'] = newValue; + _selectedNotes = newValue!; }, validator: (value) { const minLength = 0; const maxLength = 100; if (value!.isNotEmpty && (value.length < minLength || value.length > maxLength)) { - return AppLocalizations.of(context).enterCharacters( + return i18n.enterCharacters( minLength.toString(), maxLength.toString(), ); @@ -319,44 +451,48 @@ class MeasurementEntryForm extends StatelessWidget { ), ElevatedButton( - child: Text(AppLocalizations.of(context).save), - onPressed: () async { - // Validate and save the current values to the weightEntry - final isValid = _form.currentState!.validate(); - if (!isValid) { - return; - } - _form.currentState!.save(); + onPressed: _isSubmitting + ? null + : () async { + // Validate and save the current values to the weightEntry + final isValid = _form.currentState!.validate(); + if (!isValid) { + return; + } + setState(() => _isSubmitting = true); + _form.currentState!.save(); - // Save the entry on the server - _entryData['id'] == null - ? await Provider.of( - context, - listen: false, - ).addEntry( - MeasurementEntry( - id: _entryData['id'], - category: _entryData['category'], - date: _entryData['date'], - value: _entryData['value'], - notes: _entryData['notes'], - ), - ) - : await Provider.of( - context, - listen: false, - ).editEntry( - _entryData['id'], - _entryData['category'], - _entryData['value'], - _entryData['notes'], - _entryData['date'], - ); - - if (context.mounted) { - Navigator.of(context).pop(); - } - }, + final provider = Provider.of(context, listen: false); + try { + // Save the entry on the server + widget.entry == null + ? await provider.addEntry( + MeasurementEntry( + id: null, + category: _categoryId, + date: _selectedDateTime, + value: _selectedValue, + notes: _selectedNotes, + ), + ) + : await provider.editEntry( + widget.entry!.id!, + _categoryId, + _selectedValue, + _selectedNotes, + _selectedDateTime, + ); + + if (context.mounted) { + Navigator.of(context).pop(); + } + } catch (e) { + debugPrint('Save failed: $e'); + } finally { + setState(() => _isSubmitting = false); + } + }, + child: Text(i18n.save), ), ], ), From 38c0a168b3a6350a3860a54eb8f19912b88ce22e Mon Sep 17 00:00:00 2001 From: pankaj-basnet <165250380+pankaj-basnet@users.noreply.github.com> Date: Tue, 19 May 2026 19:06:47 +0545 Subject: [PATCH 2/2] adjust code files as per backend measurement entryurl endpoint --- .../measurements/measurement_category.dart | 22 +- .../measurements/measurement_category.g.dart | 7 +- .../measurements/measurement_group.dart | 8 +- .../measurements/measurement_group.g.dart | 15 +- .../measurements/mock_measurement_data.dart | 238 ++++++++++++++++++ lib/providers/measurement.dart | 53 ++-- .../measurement_categories_screen.dart | 76 +++--- lib/screens/measurement_entries_screen.dart | 69 +++-- .../dashboard/widgets/measurements.dart | 186 +++++++------- lib/widgets/measurements/categories.dart | 29 ++- lib/widgets/measurements/entries.dart | 82 +++--- lib/widgets/measurements/forms.dart | 50 +--- 12 files changed, 531 insertions(+), 304 deletions(-) create mode 100644 lib/models/measurements/mock_measurement_data.dart diff --git a/lib/models/measurements/measurement_category.dart b/lib/models/measurements/measurement_category.dart index eb268b0e5..46692f93e 100644 --- a/lib/models/measurements/measurement_category.dart +++ b/lib/models/measurements/measurement_category.dart @@ -23,11 +23,11 @@ class MeasurementCategory extends Equatable { @JsonKey(name: 'group_detail', includeToJson: false) final MeasurementGroup? groupDetail; - @JsonKey(name: 'formula', includeToJson: true) + @JsonKey(name: 'dynamic_type', includeToJson: true) final String? formula; - @JsonKey(name: 'is_dynamic', includeToJson: false, defaultValue: false) - final bool isDynamic; + @JsonKey(name: 'dynamic_params', includeToJson: true, defaultValue: {}) + final Map dynamicParams; @JsonKey(defaultValue: [], toJson: _nullValue) final List entries; @@ -39,28 +39,32 @@ class MeasurementCategory extends Equatable { this.groupId, this.groupDetail, this.formula, - this.isDynamic = false, + this.dynamicParams = const {}, this.entries = const [], }); + bool get isDynamic => formula != null && formula != 'NONE'; + MeasurementCategory copyWith({ int? id, String? name, String? unit, int? groupId, + bool clearGroup = false, MeasurementGroup? groupDetail, + bool clearGroupDetail = false, String? formula, - bool? isDynamic, + Map? dynamicParams, List? entries, }) { return MeasurementCategory( id: id ?? this.id, name: name ?? this.name, unit: unit ?? this.unit, - groupId: groupId ?? this.groupId, - groupDetail: groupDetail ?? this.groupDetail, + groupId: clearGroup ? null : (groupId ?? this.groupId), + groupDetail: clearGroupDetail ? null : (groupDetail ?? this.groupDetail), formula: formula ?? this.formula, - isDynamic: isDynamic ?? this.isDynamic, + dynamicParams: dynamicParams ?? this.dynamicParams, entries: entries ?? this.entries, ); } @@ -79,7 +83,7 @@ class MeasurementCategory extends Equatable { Map toJson() => _$MeasurementCategoryToJson(this); @override - List get props => [id, name, unit, groupId, formula, isDynamic, entries]; + List get props => [id, name, unit, groupId, formula, dynamicParams, entries]; // Helper function which makes the entries list of the toJson output null, as it isn't needed static Null _nullValue(List _) => null; diff --git a/lib/models/measurements/measurement_category.g.dart b/lib/models/measurements/measurement_category.g.dart index 8ead8e403..e3b6b1df5 100644 --- a/lib/models/measurements/measurement_category.g.dart +++ b/lib/models/measurements/measurement_category.g.dart @@ -18,8 +18,8 @@ MeasurementCategory _$MeasurementCategoryFromJson(Map json) { : MeasurementGroup.fromJson( json['group_detail'] as Map, ), - formula: json['formula'] as String?, - isDynamic: json['is_dynamic'] as bool? ?? false, + formula: json['dynamic_type'] as String?, + dynamicParams: json['dynamic_params'] as Map? ?? {}, entries: (json['entries'] as List?) ?.map((e) => MeasurementEntry.fromJson(e as Map)) @@ -35,6 +35,7 @@ Map _$MeasurementCategoryToJson( 'name': instance.name, 'unit': instance.unit, 'group': instance.groupId, - 'formula': instance.formula, + 'dynamic_type': instance.formula, + 'dynamic_params': instance.dynamicParams, 'entries': MeasurementCategory._nullValue(instance.entries), }; diff --git a/lib/models/measurements/measurement_group.dart b/lib/models/measurements/measurement_group.dart index 93fd211e5..6dec86342 100644 --- a/lib/models/measurements/measurement_group.dart +++ b/lib/models/measurements/measurement_group.dart @@ -1,4 +1,3 @@ - import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; @@ -22,16 +21,15 @@ class MeasurementGroup extends Equatable { const MeasurementGroup({ required this.id, - required this.uuid, + this.uuid = '', required this.name, this.description = '', }); - factory MeasurementGroup.fromJson(Map json) => - _$MeasurementGroupFromJson(json); + factory MeasurementGroup.fromJson(Map json) => _$MeasurementGroupFromJson(json); Map toJson() => _$MeasurementGroupToJson(this); @override List get props => [id, uuid, name, description]; -} \ No newline at end of file +} diff --git a/lib/models/measurements/measurement_group.g.dart b/lib/models/measurements/measurement_group.g.dart index 51fee1ae7..59e4cfd1b 100644 --- a/lib/models/measurements/measurement_group.g.dart +++ b/lib/models/measurements/measurement_group.g.dart @@ -10,16 +10,15 @@ MeasurementGroup _$MeasurementGroupFromJson(Map json) { $checkKeys(json, requiredKeys: const ['id', 'uuid', 'name']); return MeasurementGroup( id: (json['id'] as num).toInt(), - uuid: json['uuid'] as String, + uuid: json['uuid'] as String? ?? '', name: json['name'] as String, description: json['description'] as String? ?? '', ); } -Map _$MeasurementGroupToJson(MeasurementGroup instance) => - { - 'id': instance.id, - 'uuid': instance.uuid, - 'name': instance.name, - 'description': instance.description, - }; +Map _$MeasurementGroupToJson(MeasurementGroup instance) => { + 'id': instance.id, + 'uuid': instance.uuid, + 'name': instance.name, + 'description': instance.description, +}; diff --git a/lib/models/measurements/mock_measurement_data.dart b/lib/models/measurements/mock_measurement_data.dart new file mode 100644 index 000000000..dcb9dac5a --- /dev/null +++ b/lib/models/measurements/mock_measurement_data.dart @@ -0,0 +1,238 @@ +import 'package:wger/models/measurements/measurement_category.dart'; +import 'package:wger/models/measurements/measurement_entry.dart'; +import 'package:wger/models/measurements/measurement_group.dart'; + +// ignore: avoid_classes_with_only_static_members +class MeasurementMockData { + static List get dummyGroups => [ + const MeasurementGroup( + id: 9901, + name: 'Body Composition (Mock)', + ), + const MeasurementGroup( + id: 9902, + name: 'Cardiovascular Metrics (Mock)', + ), + const MeasurementGroup( + id: 9903, + name: 'Strength Performance (Mock)', + ), + ]; + + static List get dummyCategories { + final now = DateTime.now(); + + return [ + MeasurementCategory( + id: 8801, + name: 'Weight (Mock)', + unit: 'kg', + groupId: 9901, + formula: 'NONE', + entries: [ + MeasurementEntry( + id: 7701, + category: 8801, + date: now.subtract(const Duration(days: 14)), + value: 89.5, + notes: 'Initial mock reading.', + ), + MeasurementEntry( + id: 7702, + category: 8801, + date: now.subtract(const Duration(days: 7)), + value: 88.8, + notes: 'Morning weight after fasting.', + ), + MeasurementEntry( + id: 7703, + category: 8801, + date: now.subtract(const Duration(days: 1)), + value: 88.2, + notes: 'Consistent drop tracked.', + ), + MeasurementEntry( + id: 7726, + category: 8801, + date: now.subtract(const Duration(days: 32)), + value: 88.4, + notes: 'Auto-generated mock reading 5.', + ), + MeasurementEntry( + id: 7725, + category: 8801, + date: now.subtract(const Duration(days: 30)), + value: 87.9, + notes: 'Auto-generated mock reading 6.', + ), + MeasurementEntry( + id: 7724, + category: 8801, + date: now.subtract(const Duration(days: 28)), + value: 87.5, + notes: 'Auto-generated mock reading 7.', + ), + MeasurementEntry( + id: 7723, + category: 8801, + date: now.subtract(const Duration(days: 26)), + value: 87.3, + notes: 'Auto-generated mock reading 8.', + ), + MeasurementEntry( + id: 7722, + category: 8801, + date: now.subtract(const Duration(days: 24)), + value: 87.1, + notes: 'Auto-generated mock reading 9.', + ), + MeasurementEntry( + id: 7721, + category: 8801, + date: now.subtract(const Duration(days: 22)), + value: 86.6, + notes: 'Auto-generated mock reading 10.', + ), + MeasurementEntry( + id: 7720, + category: 8801, + date: now.subtract(const Duration(days: 20)), + value: 86.4, + notes: 'Auto-generated mock reading 11.', + ), + MeasurementEntry( + id: 7719, + category: 8801, + date: now.subtract(const Duration(days: 18)), + value: 86.0, + notes: 'Auto-generated mock reading 12.', + ), + MeasurementEntry( + id: 7718, + category: 8801, + date: now.subtract(const Duration(days: 16)), + value: 85.7, + notes: 'Auto-generated mock reading 13.', + ), + MeasurementEntry( + id: 7717, + category: 8801, + date: now.subtract(const Duration(days: 14)), + value: 85.4, + notes: 'Auto-generated mock reading 14.', + ), + MeasurementEntry( + id: 7716, + category: 8801, + date: now.subtract(const Duration(days: 12)), + value: 85.3, + notes: 'Auto-generated mock reading 15.', + ), + MeasurementEntry( + id: 7715, + category: 8801, + date: now.subtract(const Duration(days: 10)), + value: 84.9, + notes: 'Auto-generated mock reading 16.', + ), + MeasurementEntry( + id: 7714, + category: 8801, + date: now.subtract(const Duration(days: 8)), + value: 84.8, + notes: 'Auto-generated mock reading 17.', + ), + MeasurementEntry( + id: 7713, + category: 8801, + date: now.subtract(const Duration(days: 6)), + value: 84.7, + notes: 'Auto-generated mock reading 18.', + ), + MeasurementEntry( + id: 7712, + category: 8801, + date: now.subtract(const Duration(days: 4)), + value: 84.2, + notes: 'Auto-generated mock reading 19.', + ), + MeasurementEntry( + id: 7711, + category: 8801, + date: now.subtract(const Duration(days: 2)), + value: 83.9, + notes: 'Auto-generated mock reading 20.', + ), + ], + ), + + // BMI + MeasurementCategory( + id: 8802, + name: 'Body Mass Index (Mock Formula)', + unit: 'index', + groupId: 9901, + formula: 'BMI', + entries: [ + MeasurementEntry( + id: 7704, + category: 8802, + date: now.subtract(const Duration(days: 14)), + value: 24.5, + notes: 'Computed dynamically from height/weight metrics.', + ), + MeasurementEntry( + id: 7705, + category: 8802, + date: now.subtract(const Duration(days: 7)), + value: 24.2, + notes: 'Auto-updated baseline entry.', + ), + ], + ), + + // Chest Circumference has No Assigned Group Link + MeasurementCategory( + id: 8803, + name: 'Chest Circumference (Mock)', + unit: 'cm', + groupId: null, + formula: 'NONE', + entries: [ + MeasurementEntry( + id: 7706, + category: 8803, + date: now.subtract(const Duration(days: 30)), + value: 104.0, + notes: 'Baseline measurement.', + ), + MeasurementEntry( + id: 7707, + category: 8803, + date: now, + value: 106.5, + notes: 'Hypertrophy progress visible.', + ), + ], + ), + + // Blood Pressure + MeasurementCategory( + id: 8804, + name: 'Systolic Blood Pressure (Mock)', + unit: 'mmHg', + groupId: 9902, + formula: 'NONE', + entries: [ + MeasurementEntry( + id: 7708, + category: 8804, + date: now.subtract(const Duration(days: 3)), + value: 120.0, + notes: 'Normal home rest conditions.', + ), + ], + ), + ]; + } +} diff --git a/lib/providers/measurement.dart b/lib/providers/measurement.dart index 213967177..a144b787b 100644 --- a/lib/providers/measurement.dart +++ b/lib/providers/measurement.dart @@ -24,6 +24,7 @@ import 'package:wger/helpers/consts.dart'; import 'package:wger/models/measurements/measurement_category.dart'; import 'package:wger/models/measurements/measurement_entry.dart'; import 'package:wger/models/measurements/measurement_group.dart'; +import 'package:wger/models/measurements/mock_measurement_data.dart'; import 'package:wger/providers/base_provider.dart'; class MeasurementProvider with ChangeNotifier { @@ -32,13 +33,16 @@ class MeasurementProvider with ChangeNotifier { static const _categoryUrl = 'measurement-category'; static const _entryUrl = 'measurement'; static const _groupUrl = 'measurement-group'; - static const _dynamicValuesAction = 'dynamic-values'; final WgerBaseProvider baseProvider; List _categories = []; List _groups = []; - MeasurementProvider(this.baseProvider); + MeasurementProvider(this.baseProvider) { + // TODO: REMOVE before merge — loads mock data for UI development + _groups = MeasurementMockData.dummyGroups; + _categories = MeasurementMockData.dummyCategories; + } List get categories => _categories; List get groups => _groups; @@ -75,11 +79,6 @@ class MeasurementProvider with ChangeNotifier { Future fetchAndSetCategoryEntries(int id) async { final category = findCategoryById(id); - if (category.isDynamic) { - await fetchDynamicCategoryEntries(id); - return; - } - final categoryIndex = _categories.indexOf(category); // Process the response @@ -98,34 +97,6 @@ class MeasurementProvider with ChangeNotifier { notifyListeners(); } - /// Fetches computed values for a dynamic (formula-based) category - /// and sets the measurement entries - Future fetchDynamicCategoryEntries(int categoryId) async { - final category = findCategoryById(categoryId); - if (!category.isDynamic) return; - - final categoryIndex = _categories.indexOf(category); - - final requestUrl = baseProvider.makeUrl( - '$_categoryUrl/$categoryId/$_dynamicValuesAction', - ); - - // The dynamic-values endpoint returns a plain list, not paginated. - final dynamic rawData = await baseProvider.fetch(requestUrl); - final List data = rawData is List ? rawData : (rawData['results'] ?? []); - - final List computedEntries = data - .map((e) => MeasurementEntry.fromJson(e as Map)) - .toList(); - - final MeasurementCategory editedCategory = category.copyWith( - entries: computedEntries, - ); - _categories.removeAt(categoryIndex); - _categories.insert(categoryIndex, editedCategory); - notifyListeners(); - } - /// Fetches and sets the measurement categories and their entries Future fetchAndSetAllCategoriesAndEntries() async { _logger.info('Fetching all measurement categories and entries'); @@ -175,8 +146,13 @@ class MeasurementProvider with ChangeNotifier { }) async { final MeasurementCategory oldCategory = findCategoryById(id); final int categoryIndex = _categories.indexOf(oldCategory); - final MeasurementCategory tempNewCategory = oldCategory.copyWith(name: newName, unit: newUnit); - + final MeasurementCategory tempNewCategory = oldCategory.copyWith( + name: newName, + unit: newUnit, + groupId: newGroupId, + clearGroup: clearGroup, + formula: newFormula, + ); final Map response = await baseProvider.patch( tempNewCategory.toJson(), baseProvider.makeUrl(_categoryUrl, id: id), @@ -288,8 +264,6 @@ class MeasurementProvider with ChangeNotifier { } /// Edits a measurement entry - /// Currently there isn't any fallback if the call to the api is unsuccessful, as - /// WgerBaseProvider.patch only returns the response body and not the whole response Future editEntry( int id, int categoryId, @@ -298,6 +272,7 @@ class MeasurementProvider with ChangeNotifier { DateTime? newDate, ) async { final MeasurementCategory category = findCategoryById(categoryId); + final MeasurementEntry oldEntry = category.findEntryById(id); final int entryIndex = category.entries.indexOf(oldEntry); final MeasurementEntry tempNewEntry = oldEntry.copyWith( diff --git a/lib/screens/measurement_categories_screen.dart b/lib/screens/measurement_categories_screen.dart index 5aa28a2a2..6934344c3 100644 --- a/lib/screens/measurement_categories_screen.dart +++ b/lib/screens/measurement_categories_screen.dart @@ -26,7 +26,6 @@ import 'package:wger/widgets/measurements/categories.dart'; import 'package:wger/widgets/measurements/forms.dart'; class MeasurementCategoriesScreen extends StatefulWidget { - const MeasurementCategoriesScreen(); static const routeName = '/measurement-categories'; @@ -37,7 +36,6 @@ class MeasurementCategoriesScreen extends StatefulWidget { class _MeasurementCategoriesScreenState extends State { int? _selectedGroupId; - @override Widget build(BuildContext context) { return Scaffold( @@ -50,7 +48,7 @@ class _MeasurementCategoriesScreenState extends State( builder: (context, provider, child) { - - final groups = provider.groups; - if (groups.isEmpty) return const SizedBox.shrink(); + final groups = provider.groups; + + return Column( + children: [ + // Group filter chips + if (groups.isNotEmpty) + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + children: [ + FilterChip( + label: const Text('All'), + selected: _selectedGroupId == null, + onSelected: (_) => setState(() => _selectedGroupId = null), + ), + const SizedBox(width: 6), + ...groups.map( + (group) => Padding( + padding: const EdgeInsets.only(right: 6), + child: FilterChip( + label: Text(group.name), + selected: _selectedGroupId == group.id, + onSelected: (_) => setState( + () => _selectedGroupId = _selectedGroupId == group.id + ? null + : group.id, + ), + ), + ), + ), + ], + ), + ), - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Row( - children: [ - // clears group filter - FilterChip( - label: const Text('All'), - selected: _selectedGroupId == null, - onSelected: (_) => setState(() => _selectedGroupId = null), - ), - const SizedBox(width: 6), - // One chip per group - ...groups.map( - (group) => Padding( - padding: const EdgeInsets.only(right: 6), - child: FilterChip( - label: Text(group.name), - selected: _selectedGroupId == group.id, - onSelected: (_) => setState( - () => _selectedGroupId = - _selectedGroupId == group.id ? null : group.id, + Expanded( + child: CategoriesList(_selectedGroupId), ), - ), - ), - ), - CategoriesList(_selectedGroupId), - ], - ), - ); - } + ], + ); + }, ), ), ); diff --git a/lib/screens/measurement_entries_screen.dart b/lib/screens/measurement_entries_screen.dart index b8a5728af..43623f7bd 100644 --- a/lib/screens/measurement_entries_screen.dart +++ b/lib/screens/measurement_entries_screen.dart @@ -55,28 +55,54 @@ class MeasurementEntriesScreen extends StatelessWidget { } if (category.isDynamic) { - return Container( - margin: const EdgeInsets.all(8), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), - ), - child: Row( - children: [ - const Icon(Icons.auto_graph, color: Colors.blue, size: 18), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Values for this category are calculated automatically ' - 'using the "${category.formula}" formula. ' - 'You cannot add entries manually.', - style: const TextStyle(fontSize: 13), - ), + return Scaffold( + appBar: AppBar( + title: Text(category.name), + actions: [ + Chip( + avatar: const Icon(Icons.auto_awesome, size: 14), + label: const Text('Auto-calculated'), + backgroundColor: Colors.blue.withValues(alpha: 0.15), ), + const SizedBox(width: 8), ], ), + body: WidescreenWrapper( + child: SingleChildScrollView( + child: Column( + children: [ + // Info banner + Container( + margin: const EdgeInsets.all(12), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + const Icon(Icons.auto_graph, color: Colors.blue, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Values for this category are calculated automatically ' + 'using the "${category.formula == 'NONE' || category.formula == null ? 'formula' : category.formula!.toLowerCase()}" formula. ' + 'You cannot add entries manually.', + style: const TextStyle(fontSize: 13), + ), + ), + ], + ), + ), + // Show the computed entries read-only + Consumer( + builder: (context, provider, child) => EntriesList(category!), + ), + ], + ), + ), + ), ); } @@ -94,7 +120,7 @@ class MeasurementEntriesScreen extends StatelessWidget { FormScreen.routeName, arguments: FormScreenArguments( AppLocalizations.of(context).edit, - MeasurementCategoryForm(), + MeasurementCategoryForm(category: category), ), ); break; @@ -128,9 +154,8 @@ class MeasurementEntriesScreen extends StatelessWidget { // Close the popup Navigator.of(contextDialog).pop(); - Navigator.of(context).pop(); // Exit detail screen + Navigator.of(context).pop(); - // and inform the user ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( diff --git a/lib/widgets/dashboard/widgets/measurements.dart b/lib/widgets/dashboard/widgets/measurements.dart index 498028aae..a9794afe6 100644 --- a/lib/widgets/dashboard/widgets/measurements.dart +++ b/lib/widgets/dashboard/widgets/measurements.dart @@ -40,105 +40,107 @@ class _DashboardMeasurementWidgetState extends State @override Widget build(BuildContext context) { - final provider = Provider.of(context, listen: false); + // final provider = Provider.of(context, listen: false); - final items = provider.categories - .map((item) => CategoriesCard(item, elevation: 0)) - .toList(); - if (items.isNotEmpty) { - items.add( - NothingFound( - AppLocalizations.of(context).moreMeasurementEntries, - AppLocalizations.of(context).newEntry, - MeasurementCategoryForm(), - ), - ); - } return Consumer( - builder: (context, _, _) => Card( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: Text( - AppLocalizations.of(context).measurements, - style: Theme.of(context).textTheme.headlineSmall, - ), - leading: FaIcon( - FontAwesomeIcons.chartLine, - color: Theme.of(context).textTheme.headlineSmall!.color, - ), - // TODO: this icon feels out of place and inconsistent with all - // other dashboard widgets. - // maybe we should just add a "Go to all" at the bottom of the widget - trailing: IconButton( - icon: const Icon(Icons.arrow_forward), - onPressed: () => Navigator.pushNamed( - context, - MeasurementCategoriesScreen.routeName, + builder: (context, provider, _) { + final items = provider.categories + .map((item) => CategoriesCard(item, elevation: 0)) + .toList(); + if (items.isNotEmpty) { + items.add( + NothingFound( + AppLocalizations.of(context).moreMeasurementEntries, + AppLocalizations.of(context).newEntry, + MeasurementCategoryForm(), + ), + ); + } + return Card( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text( + AppLocalizations.of(context).measurements, + style: Theme.of(context).textTheme.headlineSmall, + ), + leading: FaIcon( + FontAwesomeIcons.chartLine, + color: Theme.of(context).textTheme.headlineSmall!.color, + ), + // TODO: this icon feels out of place and inconsistent with all + // other dashboard widgets. + // maybe we should just add a "Go to all" at the bottom of the widget + trailing: IconButton( + icon: const Icon(Icons.arrow_forward), + onPressed: () => Navigator.pushNamed( + context, + MeasurementCategoriesScreen.routeName, + ), ), ), - ), - Column( - children: [ - if (items.isNotEmpty) - Column( - children: [ - CarouselSlider( - items: items, - carouselController: _controller, - options: CarouselOptions( - autoPlay: false, - enlargeCenterPage: false, - viewportFraction: 1, - enableInfiniteScroll: false, - aspectRatio: 1.1, - onPageChanged: (index, reason) { - setState(() { - _current = index; - }); - }, + Column( + children: [ + if (items.isNotEmpty) + Column( + children: [ + CarouselSlider( + items: items, + carouselController: _controller, + options: CarouselOptions( + autoPlay: false, + enlargeCenterPage: false, + viewportFraction: 1, + enableInfiniteScroll: false, + aspectRatio: 1.1, + onPageChanged: (index, reason) { + setState(() { + _current = index; + }); + }, + ), ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: items.asMap().entries.map((entry) { - return GestureDetector( - onTap: () => _controller.animateToPage(entry.key), - child: Container( - width: 12.0, - height: 12.0, - margin: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 4.0, - ), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Theme.of(context).textTheme.headlineSmall!.color! - .withValues( - alpha: _current == entry.key ? 0.9 : 0.4, - ), + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: items.asMap().entries.map((entry) { + return GestureDetector( + onTap: () => _controller.animateToPage(entry.key), + child: Container( + width: 12.0, + height: 12.0, + margin: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 4.0, + ), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).textTheme.headlineSmall!.color! + .withValues( + alpha: _current == entry.key ? 0.9 : 0.4, + ), + ), ), - ), - ); - }).toList(), + ); + }).toList(), + ), ), - ), - ], - ) - else - NothingFound( - AppLocalizations.of(context).noMeasurementEntries, - AppLocalizations.of(context).newEntry, - MeasurementCategoryForm(), - ), - ], - ), - ], - ), - ), + ], + ) + else + NothingFound( + AppLocalizations.of(context).noMeasurementEntries, + AppLocalizations.of(context).newEntry, + MeasurementCategoryForm(), + ), + ], + ), + ], + ), + ); + }, ); } } diff --git a/lib/widgets/measurements/categories.dart b/lib/widgets/measurements/categories.dart index a00905175..a8ead1a68 100644 --- a/lib/widgets/measurements/categories.dart +++ b/lib/widgets/measurements/categories.dart @@ -23,24 +23,31 @@ import 'package:wger/providers/measurement.dart'; import 'categories_card.dart'; class CategoriesList extends StatelessWidget { - final int? _selectedGrouptID; - const CategoriesList(this._selectedGrouptID); + final int? _selectedGroupID; + const CategoriesList([this._selectedGroupID]); @override Widget build(BuildContext context) { final provider = Provider.of(context, listen: false); - final categories = provider.categories.where((cat) { - if (_selectedGrouptID == null) return true; - return cat.groupId == _selectedGrouptID; - }).toList(); + // Filter by group if a chip is selected + final visibleCategories = _selectedGroupID == null + ? provider.categories + : provider.categories.where((c) => c.groupId == _selectedGroupID).toList(); return RefreshIndicator( onRefresh: () => provider.fetchAndSetAllCategoriesAndEntries(), - child: ListView.builder( - padding: const EdgeInsets.all(10.0), - itemCount: categories.length, - itemBuilder: (context, index) => CategoriesCard(categories[index]), - ), + child: visibleCategories.isEmpty + ? const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: Text('No categories found.'), + ), + ) + : ListView.builder( + padding: const EdgeInsets.all(10.0), + itemCount: visibleCategories.length, + itemBuilder: (context, index) => CategoriesCard(visibleCategories[index]), + ), ); } } diff --git a/lib/widgets/measurements/entries.dart b/lib/widgets/measurements/entries.dart index 6985a1bc4..cb82b6d69 100644 --- a/lib/widgets/measurements/entries.dart +++ b/lib/widgets/measurements/entries.dart @@ -69,48 +69,54 @@ class EntriesList extends StatelessWidget { child: ListTile( title: Text('${numberFormat.format(currentEntry.value)} ${_category.unit}'), subtitle: Text(datetimeFormat.format(currentEntry.date)), - trailing: PopupMenuButton( - itemBuilder: (BuildContext context) { - return [ - PopupMenuItem( - child: Text(AppLocalizations.of(context).edit), - onTap: () => Navigator.pushNamed( - context, - FormScreen.routeName, - arguments: FormScreenArguments( - AppLocalizations.of(context).edit, - MeasurementEntryForm( - categoryId: currentEntry.category, - entry: currentEntry, - ), - ), - ), - ), - PopupMenuItem( - child: Text(AppLocalizations.of(context).delete), - onTap: () async { - // Delete entry from DB - await provider.deleteEntry( - currentEntry.id!, - currentEntry.category, - ); - - // and inform the user - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context).successfullyDeleted, - textAlign: TextAlign.center, + // Hide edit/delete entry for Dynamic category + trailing: _category.isDynamic + ? const Tooltip( + message: 'Auto-calculated — cannot be edited or deleted', + child: Icon(Icons.auto_graph, size: 18, color: Colors.blue), + ) + : PopupMenuButton( + itemBuilder: (BuildContext context) { + return [ + PopupMenuItem( + child: Text(AppLocalizations.of(context).edit), + onTap: () => Navigator.pushNamed( + context, + FormScreen.routeName, + arguments: FormScreenArguments( + AppLocalizations.of(context).edit, + MeasurementEntryForm( + categoryId: currentEntry.category, + entry: currentEntry, + ), ), ), - ); - } + ), + PopupMenuItem( + child: Text(AppLocalizations.of(context).delete), + onTap: () async { + // Delete entry from DB + await provider.deleteEntry( + currentEntry.id!, + currentEntry.category, + ); + + // and inform the user + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context).successfullyDeleted, + textAlign: TextAlign.center, + ), + ), + ); + } + }, + ), + ]; }, ), - ]; - }, - ), ), ); }, diff --git a/lib/widgets/measurements/forms.dart b/lib/widgets/measurements/forms.dart index b69f08015..ff217e698 100644 --- a/lib/widgets/measurements/forms.dart +++ b/lib/widgets/measurements/forms.dart @@ -39,11 +39,6 @@ class MeasurementCategoryForm extends StatefulWidget { } class _MeasurementCategoryFormState extends State { - final List _dummyGroups = [ - const MeasurementGroup(uuid: '1', id: 1, name: 'Dummy: Bodyweight'), - const MeasurementGroup(uuid: '2', id: 2, name: 'Dummy: Strength'), - ]; - final _form = GlobalKey(); late final TextEditingController nameController; late final TextEditingController unitController; @@ -55,9 +50,9 @@ class _MeasurementCategoryFormState extends State { bool _isSubmitting = false; static const Map _formulaLabels = { - 'bmi': 'Body Mass Index (BMI)', - 'lbm': 'Lean Body Mass', - '1rm_epley': '1RM — Epley formula', + 'BMI': 'Body Mass Index (BMI)', + // 'LBM': 'Lean Body Mass', + // '1RM_EPLEY': '1RM — Epley formula', }; @override @@ -69,7 +64,7 @@ class _MeasurementCategoryFormState extends State { unitController = TextEditingController(text: cat?.unit ?? ''); _selectedGroupId = cat?.groupId; - _selectedFormula = cat?.formula; + _selectedFormula = (cat?.formula == null || cat?.formula == 'NONE') ? null : cat?.formula; } @override @@ -177,6 +172,7 @@ class _MeasurementCategoryFormState extends State { final measurementProvider = Provider.of(context, listen: false); // Save the entry on the server + final formulaToSave = _selectedFormula ?? 'NONE'; try { if (_categoryId == null) { await measurementProvider.addCategory( @@ -185,7 +181,7 @@ class _MeasurementCategoryFormState extends State { name: nameController.text.trim(), unit: unitController.text.trim(), groupId: _selectedGroupId, - formula: _selectedFormula, + formula: formulaToSave, ), ); } else { @@ -194,7 +190,7 @@ class _MeasurementCategoryFormState extends State { nameController.text.trim(), unitController.text.trim(), _selectedGroupId, - _selectedFormula, + formulaToSave, clearGroup: _selectedGroupId == null, clearFormula: _selectedFormula == null, ); @@ -290,34 +286,6 @@ class _MeasurementEntryFormState extends State { _valueController.text = numberFormat.format(widget.entry!.value); } - if (measurementCategory.isDynamic) { - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.auto_graph, size: 48, color: Colors.blue), - const SizedBox(height: 12), - Text( - 'This measurement is calculated automatically.', // TODO: i18n - style: Theme.of(context).textTheme.titleMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 6), - Text( - 'Formula: ${measurementCategory.formula ?? ''}', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 8), - Text( - 'To see computed values, open the category detail page.', - textAlign: TextAlign.center, - ), - ], - ), - ); - } - return Form( key: _form, child: Column( @@ -352,7 +320,7 @@ class _MeasurementEntryFormState extends State { }, onSaved: (newValue) { final date = dateFormat.parse(newValue!); - _selectedDateTime = (_selectedDateTime as DateTime).copyWith( + _selectedDateTime = _selectedDateTime.copyWith( year: date.year, month: date.month, day: date.day, @@ -398,7 +366,7 @@ class _MeasurementEntryFormState extends State { }, onSaved: (newValue) { final time = timeFormat.parse(newValue!); - _selectedDateTime = (_selectedDateTime as DateTime).copyWith( + _selectedDateTime = _selectedDateTime.copyWith( hour: time.hour, minute: time.minute, second: time.second,