diff --git a/lib/models/measurements/measurement_category.dart b/lib/models/measurements/measurement_category.dart index 05d6ade02..46692f93e 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: 'dynamic_type', includeToJson: true) + final String? formula; + + @JsonKey(name: 'dynamic_params', includeToJson: true, defaultValue: {}) + final Map dynamicParams; + @JsonKey(defaultValue: [], toJson: _nullValue) final List entries; @@ -23,19 +36,35 @@ class MeasurementCategory extends Equatable { required this.id, required this.name, required this.unit, + this.groupId, + this.groupDetail, + this.formula, + 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, + Map? dynamicParams, List? entries, }) { return MeasurementCategory( id: id ?? this.id, name: name ?? this.name, unit: unit ?? this.unit, + groupId: clearGroup ? null : (groupId ?? this.groupId), + groupDetail: clearGroupDetail ? null : (groupDetail ?? this.groupDetail), + formula: formula ?? this.formula, + dynamicParams: dynamicParams ?? this.dynamicParams, entries: entries ?? this.entries, ); } @@ -54,7 +83,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, 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 e971c3884..e3b6b1df5 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['dynamic_type'] as String?, + dynamicParams: json['dynamic_params'] as Map? ?? {}, entries: (json['entries'] as List?) ?.map((e) => MeasurementEntry.fromJson(e as Map)) @@ -26,5 +34,8 @@ Map _$MeasurementCategoryToJson( 'id': instance.id, 'name': instance.name, 'unit': instance.unit, + 'group': instance.groupId, + '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 new file mode 100644 index 000000000..6dec86342 --- /dev/null +++ b/lib/models/measurements/measurement_group.dart @@ -0,0 +1,35 @@ +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, + 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]; +} diff --git a/lib/models/measurements/measurement_group.g.dart b/lib/models/measurements/measurement_group.g.dart new file mode 100644 index 000000000..59e4cfd1b --- /dev/null +++ b/lib/models/measurements/measurement_group.g.dart @@ -0,0 +1,24 @@ +// 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/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 6c0292f0d..a144b787b 100644 --- a/lib/providers/measurement.dart +++ b/lib/providers/measurement.dart @@ -23,6 +23,8 @@ 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/models/measurements/mock_measurement_data.dart'; import 'package:wger/providers/base_provider.dart'; class MeasurementProvider with ChangeNotifier { @@ -30,18 +32,25 @@ class MeasurementProvider with ChangeNotifier { static const _categoryUrl = 'measurement-category'; static const _entryUrl = 'measurement'; - + static const _groupUrl = 'measurement-group'; 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; /// Clears all lists void clear() { _categories = []; + _groups = []; } /// Finds the category by ID @@ -69,6 +78,7 @@ class MeasurementProvider with ChangeNotifier { /// Fetches and sets the measurement entries for the given category Future fetchAndSetCategoryEntries(int id) async { final category = findCategoryById(id); + final categoryIndex = _categories.indexOf(category); // Process the response @@ -91,6 +101,7 @@ class MeasurementProvider with ChangeNotifier { 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,11 +135,24 @@ 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); - + 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), @@ -141,6 +165,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); @@ -175,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, @@ -185,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 6feffb47a..6934344c3 100644 --- a/lib/screens/measurement_categories_screen.dart +++ b/lib/screens/measurement_categories_screen.dart @@ -25,11 +25,17 @@ 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( @@ -42,14 +48,55 @@ class MeasurementCategoriesScreen extends StatelessWidget { FormScreen.routeName, arguments: FormScreenArguments( AppLocalizations.of(context).newEntry, - MeasurementCategoryForm(), + const MeasurementCategoryForm(), ), ); }, ), body: WidescreenWrapper( child: Consumer( - builder: (context, provider, child) => const CategoriesList(), + builder: (context, provider, child) { + 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, + ), + ), + ), + ), + ], + ), + ), + + Expanded( + child: CategoriesList(_selectedGroupId), + ), + ], + ); + }, ), ), ); diff --git a/lib/screens/measurement_entries_screen.dart b/lib/screens/measurement_entries_screen.dart index 3f30ed3e3..43623f7bd 100644 --- a/lib/screens/measurement_entries_screen.dart +++ b/lib/screens/measurement_entries_screen.dart @@ -54,6 +54,58 @@ class MeasurementEntriesScreen extends StatelessWidget { return const SizedBox(); // Return empty widget until pop happens } + if (category.isDynamic) { + 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!), + ), + ], + ), + ), + ), + ); + } + return Scaffold( appBar: AppBar( title: Text(category.name), @@ -68,7 +120,7 @@ class MeasurementEntriesScreen extends StatelessWidget { FormScreen.routeName, arguments: FormScreenArguments( AppLocalizations.of(context).edit, - MeasurementCategoryForm(category), + MeasurementCategoryForm(category: category), ), ); break; @@ -102,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( @@ -145,7 +196,7 @@ class MeasurementEntriesScreen extends StatelessWidget { FormScreen.routeName, arguments: FormScreenArguments( AppLocalizations.of(context).newEntry, - MeasurementEntryForm(categoryId), + MeasurementEntryForm(categoryId: categoryId), ), ); }, 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 32abf49e6..a8ead1a68 100644 --- a/lib/widgets/measurements/categories.dart +++ b/lib/widgets/measurements/categories.dart @@ -23,18 +23,31 @@ import 'package:wger/providers/measurement.dart'; import 'categories_card.dart'; class CategoriesList extends StatelessWidget { - const CategoriesList(); + final int? _selectedGroupID; + const CategoriesList([this._selectedGroupID]); + @override Widget build(BuildContext context) { final provider = Provider.of(context, listen: false); + // 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: provider.categories.length, - itemBuilder: (context, index) => CategoriesCard(provider.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/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..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( - currentEntry.category, - 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 38734d2ac..ff217e698 100644 --- a/lib/widgets/measurements/forms.dart +++ b/lib/widgets/measurements/forms.dart @@ -23,33 +23,61 @@ 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 _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 ?? ''); + + _selectedGroupId = cat?.groupId; + _selectedFormula = (cat?.formula == null || cat?.formula == 'NONE') ? null : cat?.formula; + } - unitController.text = categoryData['unit']!; - nameController.text = categoryData['name']!; + @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 +85,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 +100,107 @@ 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(); + final formulaToSave = _selectedFormula ?? 'NONE'; + try { + if (_categoryId == null) { + await measurementProvider.addCategory( + MeasurementCategory( + id: null, + name: nameController.text.trim(), + unit: unitController.text.trim(), + groupId: _selectedGroupId, + formula: formulaToSave, + ), + ); + } else { + await measurementProvider.editCategory( + _categoryId!, + nameController.text.trim(), + unitController.text.trim(), + _selectedGroupId, + formulaToSave, + clearGroup: _selectedGroupId == null, + clearFormula: _selectedFormula == null, + ); + } + + if (context.mounted) { + Navigator.of(context).pop(); + } + } catch (e) { + setState(() => _isSubmitting = false); } }, ), @@ -131,41 +210,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; + + late DateTime _selectedDateTime; + late num _selectedValue; + late String _selectedNotes; + bool _isSubmitting = false; - _valueController.text = ''; - _notesController.text = _entryData['notes']!; + @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 +273,17 @@ 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); } return Form( @@ -193,7 +293,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 +309,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 +320,15 @@ class MeasurementEntryForm extends StatelessWidget { }, onSaved: (newValue) { final date = dateFormat.parse(newValue!); - _entryData['date'] = (_entryData['date'] as DateTime).copyWith( + _selectedDateTime = _selectedDateTime.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 +337,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 +348,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 +366,7 @@ class MeasurementEntryForm extends StatelessWidget { }, onSaved: (newValue) { final time = timeFormat.parse(newValue!); - _entryData['date'] = (_entryData['date'] as DateTime).copyWith( + _selectedDateTime = _selectedDateTime.copyWith( hour: time.hour, minute: time.minute, second: time.second, @@ -277,39 +377,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 +419,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), ), ], ),