Polish the add plate widget

This commit is contained in:
Roland Geider
2025-05-23 13:39:53 +02:00
parent 5a8df8936e
commit 0d36fe4bc3
11 changed files with 220 additions and 239 deletions

View File

@@ -130,3 +130,5 @@ const LIBERAPAY_URL = 'https://liberapay.com/wger';
/// the milliseconds themselves can cause the application to crash since it runs
/// out of memory...
const double CHART_MILLISECOND_FACTOR = 100000.0;
enum WeightUnitEnum { kg, lb }

View File

@@ -19,13 +19,14 @@
/// Calculates the number of plates needed to reach a specific weight
List<num> plateCalculator(num totalWeight, num barWeight, List<num> plates) {
final List<num> result = [];
final sortedPlates = List.of(plates)..sort();
// Weight is less than the bar
if (totalWeight < barWeight) {
return [];
}
if (plates.isEmpty) {
if (sortedPlates.isEmpty) {
return [];
}
@@ -33,12 +34,12 @@ List<num> plateCalculator(num totalWeight, num barWeight, List<num> plates) {
totalWeight = (totalWeight - barWeight) / 2;
// Weight can't be divided with the smallest plate
if (totalWeight % plates.first > 0) {
if (totalWeight % sortedPlates.first > 0) {
return [];
}
// Iterate through the plates, beginning with the biggest ones
for (final plate in plates.reversed) {
for (final plate in sortedPlates.reversed) {
while (totalWeight >= plate) {
totalWeight -= plate;
result.add(plate);

View File

@@ -90,20 +90,20 @@ void main() async {
await PreferenceHelper.instance.migrationSupportFunctionForSharedPreferences();
// Catch errors from Flutter itself (widget build, layout, paint, etc.)
FlutterError.onError = (FlutterErrorDetails details) {
final stack = details.stack ?? StackTrace.empty;
if (kDebugMode) {
FlutterError.dumpErrorToConsole(details);
}
// Don't show the full error dialog for network image loading errors.
if (details.exception is NetworkImageLoadException) {
return;
}
showGeneralErrorDialog(details.exception, stack);
// throw details.exception;
};
// FlutterError.onError = (FlutterErrorDetails details) {
// final stack = details.stack ?? StackTrace.empty;
// if (kDebugMode) {
// FlutterError.dumpErrorToConsole(details);
// }
//
// // Don't show the full error dialog for network image loading errors.
// if (details.exception is NetworkImageLoadException) {
// return;
// }
//
// // showGeneralErrorDialog(details.exception, stack);
// throw details.exception;
// };
// Catch errors that happen outside of the Flutter framework (e.g., in async operations)
PlatformDispatcher.instance.onError = (error, stack) {
@@ -114,8 +114,8 @@ void main() async {
if (error is WgerHttpException) {
showHttpExceptionErrorDialog(error);
} else {
showGeneralErrorDialog(error, stack);
// throw error;
// showGeneralErrorDialog(error, stack);
throw error;
}
// Return true to indicate that the error has been handled.

View File

@@ -2,10 +2,14 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/gym_mode.dart';
import 'package:wger/helpers/shared_preferences.dart';
const DEFAULT_KG_PLATES = [2.5, 5, 10, 15, 20, 25];
const DEFAULT_LB_PLATES = [2.5, 5, 10, 25, 35, 45];
const PREFS_KEY_PLATES = 'selectedPlates';
@@ -14,6 +18,12 @@ final plateWeightsProvider = StateNotifierProvider<PlateWeightsNotifier, PlateWe
});
class PlateWeightsState {
final _logger = Logger('PlateWeightsState');
final barWeightKg = 20;
final barWeightLb = 45;
// https://en.wikipedia.org/wiki/Barbell#Bumper_plates
final Map<double, Color> plateColorMapKg = {
25: Colors.red,
20: Colors.blue,
@@ -33,26 +43,29 @@ class PlateWeightsState {
25: Colors.green,
10: Colors.white,
5: Colors.blue,
2.5: Colors.green,
1.25: Colors.white,
};
final bool isMetric;
final num totalWeight;
final num barWeight;
final List<num> selectedPlates;
final List<num> kgWeights = const [0.5, 1, 1.25, 2, 2.5, 5, 10, 15, 20, 25];
final List<num> lbsWeights = const [2.5, 5, 10, 25, 35, 45];
final List<num> availablePlatesKg = const [0.5, 1, 1.25, 2, 2.5, 5, 10, 15, 20, 25];
final List<num> availablePlatesLb = const [2.5, 5, 10, 25, 35, 45];
PlateWeightsState({
this.isMetric = true,
this.totalWeight = 0,
this.barWeight = 20,
List<num>? selectedPlates,
}) : selectedPlates = selectedPlates ?? [...DEFAULT_KG_PLATES];
num get totalWeightInKg => isMetric ? totalWeight : totalWeight / 2.205;
num get barWeight {
return isMetric ? barWeightKg : barWeightLb;
}
num get barWeightInKg => isMetric ? barWeight : barWeight / 2.205;
List<num> get availablePlates {
return isMetric ? availablePlatesKg : availablePlatesLb;
}
List<num> get platesList {
return plateCalculator(totalWeight, barWeight, selectedPlates);
@@ -63,8 +76,7 @@ class PlateWeightsState {
}
Map<num, int> get calculatePlates {
List<num> sortedPlates = List.from(selectedPlates)..sort();
return groupPlates(plateCalculator(totalWeight, barWeight, sortedPlates));
return groupPlates(plateCalculator(totalWeight, barWeight, selectedPlates));
}
Color getColor(num plate) {
@@ -74,91 +86,87 @@ class PlateWeightsState {
return plateColorMapLb[plate.toDouble()] ?? Colors.grey;
}
Map<String, dynamic> toJson() {
return {'isMetric': isMetric, 'selectedPlates': selectedPlates};
}
PlateWeightsState copyWith({
bool? isMetric,
num? totalWeight,
num? barWeight,
List<num>? selectedPlates,
}) {
return PlateWeightsState(
isMetric: isMetric ?? this.isMetric,
totalWeight: totalWeight ?? this.totalWeight,
barWeight: barWeight ?? this.barWeight,
selectedPlates: selectedPlates ?? this.selectedPlates,
);
}
}
class PlateWeightsNotifier extends StateNotifier<PlateWeightsState> {
PlateWeightsNotifier() : super(PlateWeightsState()) {
_readPlates();
final _logger = Logger('PlateWeightsNotifier');
late SharedPreferencesAsync prefs;
PlateWeightsNotifier({SharedPreferencesAsync? prefs}) : super(PlateWeightsState()) {
this.prefs = prefs ?? PreferenceHelper.asyncPref;
_readDataFromSharedPrefs();
}
Future<void> _saveIntoSharedPrefs() async {
final pref = await SharedPreferences.getInstance();
pref.setString(PREFS_KEY_PLATES, jsonEncode(state.selectedPlates));
Future<void> saveToSharedPrefs() async {
await prefs.setString(PREFS_KEY_PLATES, jsonEncode(state.toJson()));
}
Future<void> _readPlates() async {
final pref = await SharedPreferences.getInstance();
final platePrefData = pref.getString(PREFS_KEY_PLATES);
if (platePrefData != null) {
Future<void> _readDataFromSharedPrefs() async {
final prefsData = await prefs.getString(PREFS_KEY_PLATES);
if (prefsData != null) {
try {
final plateData = json.decode(platePrefData);
if (plateData is List) {
state = state.copyWith(selectedPlates: plateData.cast<num>());
} else {
throw const FormatException('Not a List');
}
final plateData = json.decode(prefsData);
state = state.copyWith(
isMetric: plateData['isMetric'] ?? true,
selectedPlates: plateData['selectedPlates'].cast<num>() ?? [...DEFAULT_KG_PLATES],
);
} catch (e) {
state = state.copyWith(selectedPlates: []);
_logger.fine('Error decoding plate data from SharedPreferences: $e');
}
}
}
Future<void> toggleSelection(num x) async {
final newSelectedPlates = List<num>.from(state.selectedPlates);
final newSelectedPlates = List.of(state.selectedPlates);
if (newSelectedPlates.contains(x)) {
newSelectedPlates.remove(x);
} else {
newSelectedPlates.add(x);
}
state = state.copyWith(selectedPlates: newSelectedPlates);
await _saveIntoSharedPrefs();
await saveToSharedPrefs();
}
void unitChange() {
if (state.isMetric == false) {
void unitChange({WeightUnitEnum? unit}) {
final WeightUnitEnum changeTo =
unit ?? (state.isMetric ? WeightUnitEnum.lb : WeightUnitEnum.kg);
if (changeTo == WeightUnitEnum.kg && !state.isMetric) {
state = state.copyWith(
isMetric: true,
totalWeight: state.totalWeightInKg,
barWeight: state.barWeightInKg,
totalWeight: 0,
selectedPlates: [...DEFAULT_KG_PLATES],
);
} else {
}
if (changeTo == WeightUnitEnum.lb && state.isMetric) {
state = state.copyWith(
isMetric: false,
totalWeight: state.totalWeightInKg * 2.205,
barWeight: state.barWeightInKg * 2.205,
totalWeight: 0,
selectedPlates: [...DEFAULT_LB_PLATES],
);
}
}
void clear() async {
state = state.copyWith(selectedPlates: []);
await _saveIntoSharedPrefs();
}
void setWeight(num x) {
_logger.fine('Setting weight to $x');
state = state.copyWith(totalWeight: x);
}
void resetPlates() async {
state = state.copyWith(selectedPlates: [...DEFAULT_KG_PLATES]);
await _saveIntoSharedPrefs();
}
void selectAllPlates() async {
state = state.copyWith(selectedPlates: [...state.kgWeights]);
await _saveIntoSharedPrefs();
}
}

View File

@@ -46,13 +46,12 @@ class UserProvider with ChangeNotifier {
}
// change the unit of plates
void unitChange() {
if (profile?.weightUnitStr == 'kg') {
void changeUnit({changeTo = 'kg'}) {
if (changeTo == 'kg') {
profile?.weightUnitStr = 'lb';
} else {
profile?.weightUnitStr = 'kg';
}
ChangeNotifier();
}
// Load theme mode from SharedPreferences

View File

@@ -1,8 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:provider/provider.dart' as provider;
import 'package:wger/helpers/consts.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/providers/plate_weights.dart';
import 'package:wger/providers/user.dart';
import 'package:wger/widgets/routines/plate_calculator.dart';
class AddPlateWeights extends ConsumerStatefulWidget {
const AddPlateWeights({super.key});
@@ -13,8 +16,7 @@ class AddPlateWeights extends ConsumerStatefulWidget {
class _AddPlateWeightsState extends ConsumerState<AddPlateWeights>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _animation;
final _unitController = TextEditingController();
@override
void initState() {
@@ -22,151 +24,79 @@ class _AddPlateWeightsState extends ConsumerState<AddPlateWeights>
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(plateWeightsProvider.notifier);
});
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
);
_animation = Tween<Offset>(
begin: const Offset(-1.0, 0.0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
_unitController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context);
final plateWeightsState = ref.watch(plateWeightsProvider);
final plateWeightsNotifier = ref.read(plateWeightsProvider.notifier);
final userProviderInstance = provider.Provider.of<UserProvider>(context);
final userProfile = userProviderInstance.profile;
final userProvider = provider.Provider.of<UserProvider>(context);
return Scaffold(
appBar: AppBar(title: const Text('Select Available Plates')),
body: Column(
mainAxisSize: MainAxisSize.max,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Preferred Unit'),
DropdownButton<String>(
value: (userProfile?.isMetric ?? true) ? 'kg' : 'lbs',
onChanged: (String? newValue) {
if (newValue == null) return;
final selectedUnitIsMetric = (newValue == 'kg');
if (selectedUnitIsMetric != (userProfile?.isMetric ?? true)) {
plateWeightsNotifier.unitChange();
provider.Provider.of<UserProvider>(context, listen: false).unitChange();
_controller.reset();
_controller.forward();
}
},
items: ['kg', 'lbs'].map((unit) {
return DropdownMenuItem<String>(
value: unit,
child: Text(unit),
Padding(
padding: const EdgeInsets.all(10),
child: DropdownMenu<WeightUnitEnum>(
width: double.infinity,
initialSelection: plateWeightsState.isMetric ? WeightUnitEnum.kg : WeightUnitEnum.lb,
controller: _unitController,
requestFocusOnTap: true,
label: Text(i18n.unit),
onSelected: (WeightUnitEnum? unit) {
if (unit == null) {
return;
}
plateWeightsNotifier.unitChange(unit: unit);
// userProvider.changeUnit(changeTo: unit.name);
// userProvider.saveProfile();
},
dropdownMenuEntries: WeightUnitEnum.values.map((unit) {
return DropdownMenuEntry<WeightUnitEnum>(
value: unit,
label: unit == WeightUnitEnum.kg ? i18n.kg : i18n.lb,
);
}).toList(),
),
),
LayoutBuilder(
builder: (context, constraints) {
const double widthThreshold = 450.0;
final int crossAxisCount = constraints.maxWidth > widthThreshold ? 10 : 5;
return GridView.count(
crossAxisCount: crossAxisCount,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: plateWeightsState.availablePlates.map((number) {
final bool isSelected = plateWeightsState.selectedPlates.contains(number);
return GestureDetector(
onTap: () => plateWeightsNotifier.toggleSelection(number),
child: PlateWeight(
value: number,
isSelected: isSelected,
color: plateWeightsState.getColor(number),
),
);
}).toList(),
),
],
);
},
),
Wrap(
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: (userProfile == null || userProfile.isMetric)
? plateWeightsState.kgWeights.map((number) {
return SlideTransition(
position: _animation,
child: GestureDetector(
onTap: () => plateWeightsNotifier.toggleSelection(number),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
height: 50,
width: 50,
alignment: Alignment.center,
margin: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: plateWeightsState.selectedPlates.contains(number)
? plateWeightsState.getColor(number)
: const Color.fromARGB(255, 97, 105, 101),
shape: BoxShape.circle,
border: Border.all(color: Colors.black, width: 2),
),
child: Text(
number.toString(),
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
),
),
),
);
}).toList()
: plateWeightsState.lbsWeights.map((number) {
return SlideTransition(
position: _animation,
child: GestureDetector(
onTap: () => plateWeightsNotifier.toggleSelection(number),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
margin: const EdgeInsets.all(8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: plateWeightsState.selectedPlates.contains(number)
? const Color.fromARGB(255, 82, 226, 236)
: const Color.fromARGB(255, 97, 105, 101),
borderRadius: BorderRadius.circular(10),
),
child: Text(
'$number lbs',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
),
);
}).toList(),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: const Text('Done'),
),
ElevatedButton(
onPressed: () {
plateWeightsNotifier.selectAllPlates();
},
child: const Text('Select all'),
),
ElevatedButton(
onPressed: () {
plateWeightsNotifier.resetPlates();
},
child: const Text('Reset'),
),
],
FilledButton(
onPressed: () {
plateWeightsNotifier.saveToSharedPrefs();
Navigator.pop(context);
},
child: Text(i18n.save),
),
],
),

View File

@@ -36,6 +36,7 @@ import 'package:wger/widgets/routines/forms/reps_unit.dart';
import 'package:wger/widgets/routines/forms/rir.dart';
import 'package:wger/widgets/routines/forms/weight_unit.dart';
import 'package:wger/widgets/routines/gym_mode/navigation.dart';
import 'package:wger/widgets/routines/plate_calculator.dart';
class LogPage extends ConsumerStatefulWidget {
final PageController _controller;
@@ -379,21 +380,26 @@ class _LogPageState extends ConsumerState<LogPage> {
Widget getPlates() {
final plateWeightsState = ref.watch(plateWeightsProvider);
return Column(
children: [
Text(
AppLocalizations.of(context).plateCalculator,
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
onPressed: () {
Navigator.of(context)
.push(MaterialPageRoute(builder: (context) => const AddPlateWeights()));
},
icon: const Icon(Icons.settings),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
AppLocalizations.of(context).plateCalculator,
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
onPressed: () {
Navigator.of(context)
.push(MaterialPageRoute(builder: (context) => const AddPlateWeights()));
},
icon: const Icon(Icons.settings),
),
],
),
SizedBox(
height: 35,
child: plateWeightsState.hasPlates
? Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -403,28 +409,12 @@ class _LogPageState extends ConsumerState<LogPage> {
children: [
Text(entry.value.toString()),
const Text('×'),
Container(
decoration: BoxDecoration(
color: ref.read(plateWeightsProvider).getColor(entry.key),
shape: BoxShape.circle,
border: Border.all(color: Colors.black, width: 1),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: SizedBox(
height: 35,
width: 35,
child: Align(
alignment: Alignment.center,
child: Text(
entry.key.toString(),
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
),
),
PlateWeight(
value: entry.key,
size: 37,
padding: 2,
margin: 0,
color: ref.read(plateWeightsProvider).getColor(entry.key),
),
const SizedBox(width: 10),
],

View File

@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
class PlateWeight extends StatelessWidget {
final num value;
final Color color;
final bool isSelected;
final double size;
final double padding;
final double margin;
const PlateWeight({
super.key,
required this.value,
required this.color,
this.isSelected = true,
this.size = 50,
this.padding = 8,
this.margin = 3,
});
@override
Widget build(BuildContext context) {
return SizedBox(
height: size,
width: size,
child: Padding(
padding: EdgeInsets.all(padding),
child: Container(
alignment: Alignment.center,
margin: EdgeInsets.all(margin),
decoration: BoxDecoration(
color: isSelected ? color : Colors.black12,
shape: BoxShape.circle,
border: Border.all(color: Colors.black, width: isSelected ? 1 : 0),
),
child: Text(
value.toString(),
textAlign: TextAlign.center,
style: TextStyle(
color: isSelected ? Colors.black : Colors.black87,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
),
),
);
}
}

View File

@@ -1181,10 +1181,11 @@ class MockUserProvider extends _i1.Mock implements _i22.UserProvider {
);
@override
void unitChange() => super.noSuchMethod(
void changeUnit({dynamic changeTo = 'kg'}) => super.noSuchMethod(
Invocation.method(
#unitChange,
#changeUnit,
[],
{#changeTo: changeTo},
),
returnValueForMissingStub: null,
);

View File

@@ -593,10 +593,11 @@ class MockUserProvider extends _i1.Mock implements _i17.UserProvider {
);
@override
void unitChange() => super.noSuchMethod(
void changeUnit({dynamic changeTo = 'kg'}) => super.noSuchMethod(
Invocation.method(
#unitChange,
#changeUnit,
[],
{#changeTo: changeTo},
),
returnValueForMissingStub: null,
);

View File

@@ -340,10 +340,11 @@ class MockUserProvider extends _i1.Mock implements _i13.UserProvider {
);
@override
void unitChange() => super.noSuchMethod(
void changeUnit({dynamic changeTo = 'kg'}) => super.noSuchMethod(
Invocation.method(
#unitChange,
#changeUnit,
[],
{#changeTo: changeTo},
),
returnValueForMissingStub: null,
);