Improve decimal input handling

We now use TextInputType.numberWithOptions(decimal: true) which seems to have
a more consistent behaviour under android and iOS. Also, we now use NumberFormat
to parse the inputs according to the user's locale.
This commit is contained in:
Roland Geider
2025-06-27 13:23:40 +02:00
parent 20ff983fe6
commit 8ae889a1ea
19 changed files with 93 additions and 126 deletions

View File

@@ -132,3 +132,6 @@ const LIBERAPAY_URL = 'https://liberapay.com/wger';
const double CHART_MILLISECOND_FACTOR = 100000.0;
enum WeightUnitEnum { kg, lb }
/// TextInputType for decimal numbers
const textInputTypeDecimal = TextInputType.numberWithOptions(decimal: true);

View File

@@ -568,10 +568,10 @@ class RoutinesProvider with ChangeNotifier {
notifyListeners();
}
Future<void> handleConfig(SlotEntry entry, String input, ConfigType type) async {
Future<void> handleConfig(SlotEntry entry, num? value, ConfigType type) async {
final configs = entry.getConfigsByType(type);
final config = configs.isNotEmpty ? configs.first : null;
final value = input.isNotEmpty ? num.parse(input) : null;
// final value = input.isNotEmpty ? num.parse(input) : null;
if (value == null && config != null) {
// Value removed, delete entry

View File

@@ -17,7 +17,9 @@
*/
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/json.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/measurements/measurement_category.dart';
@@ -168,6 +170,8 @@ class MeasurementEntryForm extends StatelessWidget {
(category) => category.id == _categoryId,
);
final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString());
return Form(
key: _form,
child: Column(
@@ -218,20 +222,20 @@ class MeasurementEntryForm extends StatelessWidget {
suffixIconConstraints: const BoxConstraints(minWidth: 0, minHeight: 0),
),
controller: _valueController,
keyboardType: TextInputType.number,
keyboardType: textInputTypeDecimal,
validator: (value) {
if (value!.isEmpty) {
return AppLocalizations.of(context).enterValue;
}
try {
double.parse(value);
numberFormat.parse(value);
} catch (error) {
return AppLocalizations.of(context).enterValidNumber;
}
return null;
},
onSaved: (newValue) {
_entryData['value'] = double.parse(newValue!);
_entryData['value'] = numberFormat.parse(newValue!);
},
),
// Value

View File

@@ -17,6 +17,7 @@
*/
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/json.dart';
@@ -236,6 +237,8 @@ class IngredientFormState extends State<IngredientForm> {
final queryLower = _searchQuery.toLowerCase();
final suggestions =
widget.recent.where((e) => e.ingredient.name.toLowerCase().contains(queryLower)).toList();
final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString());
return Container(
margin: const EdgeInsets.all(20),
child: Form(
@@ -261,7 +264,7 @@ class IngredientFormState extends State<IngredientForm> {
labelText: AppLocalizations.of(context).weight,
),
controller: _amountController,
keyboardType: TextInputType.number,
keyboardType: textInputTypeDecimal,
onChanged: (value) {
setState(() {
final v = double.tryParse(value);
@@ -271,11 +274,11 @@ class IngredientFormState extends State<IngredientForm> {
});
},
onSaved: (value) {
_mealItem.amount = double.parse(value!);
_mealItem.amount = numberFormat.parse(value!);
},
validator: (value) {
try {
double.parse(value!);
numberFormat.parse(value!);
} catch (error) {
return AppLocalizations.of(context).enterValidNumber;
}
@@ -703,22 +706,24 @@ class GoalMacros extends StatelessWidget {
@override
Widget build(BuildContext context) {
final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString());
return TextFormField(
initialValue: val ?? '',
decoration: InputDecoration(labelText: label, suffixText: suffix),
keyboardType: TextInputType.number,
keyboardType: textInputTypeDecimal,
onSaved: (newValue) {
if (newValue == null || newValue == '') {
return;
}
onSave(double.parse(newValue));
onSave(numberFormat.parse(newValue) as double);
},
validator: (value) {
if (value == '') {
return null;
}
try {
double.parse(value!);
numberFormat.parse(value!);
} catch (error) {
return AppLocalizations.of(context).enterValidNumber;
}

View File

@@ -1,58 +0,0 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (C) 2020, 2021 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* wger Workout Manager is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
import 'package:wger/models/workouts/slot_entry.dart';
class RepetitionsInputWidget extends StatelessWidget {
final _repetitionsController = TextEditingController();
final SlotEntry _setting;
final bool _detailed;
RepetitionsInputWidget(this._setting, this._detailed);
@override
Widget build(BuildContext context) {
return TextFormField(
decoration: InputDecoration(
labelText: _detailed ? AppLocalizations.of(context).repetitions : '',
errorMaxLines: 2,
),
controller: _repetitionsController,
keyboardType: TextInputType.number,
validator: (value) {
try {
if (value != '') {
double.parse(value!);
}
} catch (error) {
return AppLocalizations.of(context).enterValidNumber;
}
return null;
},
onChanged: (newValue) {
if (newValue != '') {
try {
// _setting.reps = int.parse(newValue);
} catch (e) {}
}
},
);
}
}

View File

@@ -17,6 +17,7 @@
*/
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:wger/exceptions/http_exception.dart';
import 'package:wger/helpers/consts.dart';
@@ -120,6 +121,7 @@ class _SlotEntryFormState extends State<SlotEntryForm> {
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context);
final languageCode = Localizations.localeOf(context).languageCode;
final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString());
final provider = context.read<RoutinesProvider>();
@@ -206,10 +208,10 @@ class _SlotEntryFormState extends State<SlotEntryForm> {
Flexible(
child: TextFormField(
controller: weightController,
keyboardType: TextInputType.number,
keyboardType: textInputTypeDecimal,
decoration: InputDecoration(labelText: i18n.weight),
validator: (value) {
if (value != null && value != '' && double.tryParse(value) == null) {
if (value != null && value != '' && numberFormat.tryParse(value) == null) {
return i18n.enterValidNumber;
}
return null;
@@ -220,10 +222,10 @@ class _SlotEntryFormState extends State<SlotEntryForm> {
Flexible(
child: TextFormField(
controller: maxWeightController,
keyboardType: TextInputType.number,
keyboardType: textInputTypeDecimal,
decoration: InputDecoration(labelText: i18n.max),
validator: (value) {
if (value != null && value != '' && double.tryParse(value) == null) {
if (value != null && value != '' && numberFormat.tryParse(value) == null) {
return i18n.enterValidNumber;
}
return null;
@@ -245,10 +247,10 @@ class _SlotEntryFormState extends State<SlotEntryForm> {
Flexible(
child: TextFormField(
controller: repetitionsController,
keyboardType: TextInputType.number,
keyboardType: textInputTypeDecimal,
decoration: InputDecoration(labelText: i18n.repetitions),
validator: (value) {
if (value != null && value != '' && int.tryParse(value) == null) {
if (value != null && value != '' && numberFormat.tryParse(value) == null) {
return i18n.enterValidNumber;
}
return null;
@@ -259,10 +261,10 @@ class _SlotEntryFormState extends State<SlotEntryForm> {
Flexible(
child: TextFormField(
controller: maxRepetitionsController,
keyboardType: TextInputType.number,
keyboardType: textInputTypeDecimal,
decoration: InputDecoration(labelText: i18n.max),
validator: (value) {
if (value != null && value != '' && int.tryParse(value) == null) {
if (value != null && value != '' && numberFormat.tryParse(value) == null) {
return i18n.enterValidNumber;
}
return null;
@@ -325,42 +327,42 @@ class _SlotEntryFormState extends State<SlotEntryForm> {
await Future.wait([
provider.handleConfig(
widget.entry,
setsSliderValue == 0 ? '' : setsSliderValue.round().toString(),
setsSliderValue == 0 ? null : setsSliderValue.round(),
ConfigType.sets,
),
provider.handleConfig(
widget.entry,
weightController.text,
numberFormat.tryParse(weightController.text),
ConfigType.weight,
),
provider.handleConfig(
widget.entry,
maxWeightController.text,
numberFormat.tryParse(maxWeightController.text),
ConfigType.maxWeight,
),
provider.handleConfig(
widget.entry,
repetitionsController.text,
numberFormat.tryParse(repetitionsController.text),
ConfigType.repetitions,
),
provider.handleConfig(
widget.entry,
maxRepetitionsController.text,
numberFormat.tryParse(maxRepetitionsController.text),
ConfigType.maxRepetitions,
),
provider.handleConfig(
widget.entry,
restController.text,
numberFormat.tryParse(restController.text),
ConfigType.rest,
),
provider.handleConfig(
widget.entry,
maxRestController.text,
numberFormat.tryParse(maxRestController.text),
ConfigType.maxRest,
),
provider.handleConfig(
widget.entry,
rirController.text,
numberFormat.tryParse(rirController.text),
ConfigType.rir,
),
]);

View File

@@ -99,6 +99,7 @@ class _LogPageState extends ConsumerState<LogPage> {
Widget getRepsWidget() {
final repsValueChange = widget._configData.repetitionsRounding ?? 1;
final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString());
return Row(
children: [
@@ -106,7 +107,8 @@ class _LogPageState extends ConsumerState<LogPage> {
icon: const Icon(Icons.remove, color: Colors.black),
onPressed: () {
try {
final num newValue = num.parse(_repetitionsController.text) - repsValueChange;
final num newValue =
numberFormat.parse(_repetitionsController.text) - repsValueChange;
if (newValue > 0) {
_repetitionsController.text = newValue.toString();
}
@@ -120,19 +122,17 @@ class _LogPageState extends ConsumerState<LogPage> {
),
enabled: true,
controller: _repetitionsController,
keyboardType: TextInputType.number,
keyboardType: textInputTypeDecimal,
focusNode: focusNode,
onFieldSubmitted: (_) {
// Placeholder for potential future logic
},
onSaved: (newValue) {
widget._log.repetitions = num.parse(newValue!);
widget._log.repetitions = numberFormat.parse(newValue!);
focusNode.unfocus();
},
validator: (value) {
try {
num.parse(value!);
} catch (error) {
if (numberFormat.tryParse(value ?? '') == null) {
return AppLocalizations.of(context).enterValidNumber;
}
return null;
@@ -143,7 +143,8 @@ class _LogPageState extends ConsumerState<LogPage> {
icon: const Icon(Icons.add, color: Colors.black),
onPressed: () {
try {
final num newValue = num.parse(_repetitionsController.text) + repsValueChange;
final num newValue =
numberFormat.parse(_repetitionsController.text) + repsValueChange;
_repetitionsController.text = newValue.toString();
} on FormatException {}
},
@@ -154,6 +155,7 @@ class _LogPageState extends ConsumerState<LogPage> {
Widget getWeightWidget() {
final weightValueChange = widget._configData.weightRounding ?? 1.25;
final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString());
return Row(
children: [
@@ -161,13 +163,16 @@ class _LogPageState extends ConsumerState<LogPage> {
icon: const Icon(Icons.remove, color: Colors.black),
onPressed: () {
try {
final num newValue = num.parse(_weightController.text) - (2 * weightValueChange);
final num newValue =
numberFormat.parse(_weightController.text) - (2 * weightValueChange);
if (newValue > 0) {
setState(() {
widget._log.weight = newValue;
_weightController.text = newValue.toString();
ref.read(plateCalculatorProvider.notifier).setWeight(
_weightController.text == '' ? 0 : double.parse(_weightController.text),
_weightController.text == ''
? 0
: numberFormat.parse(_weightController.text),
);
});
}
@@ -186,24 +191,24 @@ class _LogPageState extends ConsumerState<LogPage> {
},
onChanged: (value) {
try {
num.parse(value);
numberFormat.parse(value);
setState(() {
widget._log.weight = num.parse(value);
widget._log.weight = numberFormat.parse(value);
ref.read(plateCalculatorProvider.notifier).setWeight(
_weightController.text == '' ? 0 : double.parse(_weightController.text),
_weightController.text == ''
? 0
: numberFormat.parse(_weightController.text),
);
});
} on FormatException {}
},
onSaved: (newValue) {
setState(() {
widget._log.weight = num.parse(newValue!);
widget._log.weight = numberFormat.parse(newValue!);
});
},
validator: (value) {
try {
num.parse(value!);
} catch (error) {
if (numberFormat.tryParse(value ?? '') == null) {
return AppLocalizations.of(context).enterValidNumber;
}
return null;
@@ -214,12 +219,13 @@ class _LogPageState extends ConsumerState<LogPage> {
icon: const Icon(Icons.add, color: Colors.black),
onPressed: () {
try {
final num newValue = num.parse(_weightController.text) + (2 * weightValueChange);
final num newValue =
numberFormat.parse(_weightController.text) + (2 * weightValueChange);
setState(() {
widget._log.weight = newValue;
_weightController.text = newValue.toString();
ref.read(plateCalculatorProvider.notifier).setWeight(
_weightController.text == '' ? 0 : double.parse(_weightController.text),
_weightController.text == '' ? 0 : numberFormat.parse(_weightController.text),
);
});
} on FormatException {}

View File

@@ -18,6 +18,7 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/json.dart';
@@ -40,6 +41,8 @@ class WeightForm extends StatelessWidget {
@override
Widget build(BuildContext context) {
final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString());
return Form(
key: _form,
child: Column(
@@ -142,16 +145,17 @@ class WeightForm extends StatelessWidget {
),
),
controller: weightController,
keyboardType: TextInputType.number,
keyboardType: textInputTypeDecimal,
onSaved: (newValue) {
_weightEntry.weight = double.parse(newValue!);
_weightEntry.weight = numberFormat.parse(newValue!);
},
validator: (value) {
if (value!.isEmpty) {
return AppLocalizations.of(context).enterValue;
}
try {
double.parse(value);
numberFormat.parse(value);
} catch (error) {
return AppLocalizations.of(context).enterValidNumber;
}

View File

@@ -48,6 +48,7 @@
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">

View File

@@ -692,7 +692,7 @@ class MockRoutinesProvider extends _i1.Mock implements _i12.RoutinesProvider {
@override
_i13.Future<void> handleConfig(
_i8.SlotEntry? entry,
String? input,
num? value,
_i8.ConfigType? type,
) =>
(super.noSuchMethod(
@@ -700,7 +700,7 @@ class MockRoutinesProvider extends _i1.Mock implements _i12.RoutinesProvider {
#handleConfig,
[
entry,
input,
value,
type,
],
),

View File

@@ -692,7 +692,7 @@ class MockRoutinesProvider extends _i1.Mock implements _i12.RoutinesProvider {
@override
_i13.Future<void> handleConfig(
_i8.SlotEntry? entry,
String? input,
num? value,
_i8.ConfigType? type,
) =>
(super.noSuchMethod(
@@ -700,7 +700,7 @@ class MockRoutinesProvider extends _i1.Mock implements _i12.RoutinesProvider {
#handleConfig,
[
entry,
input,
value,
type,
],
),

View File

@@ -692,7 +692,7 @@ class MockRoutinesProvider extends _i1.Mock implements _i12.RoutinesProvider {
@override
_i13.Future<void> handleConfig(
_i8.SlotEntry? entry,
String? input,
num? value,
_i8.ConfigType? type,
) =>
(super.noSuchMethod(
@@ -700,7 +700,7 @@ class MockRoutinesProvider extends _i1.Mock implements _i12.RoutinesProvider {
#handleConfig,
[
entry,
input,
value,
type,
],
),

View File

@@ -692,7 +692,7 @@ class MockRoutinesProvider extends _i1.Mock implements _i12.RoutinesProvider {
@override
_i13.Future<void> handleConfig(
_i8.SlotEntry? entry,
String? input,
num? value,
_i8.ConfigType? type,
) =>
(super.noSuchMethod(
@@ -700,7 +700,7 @@ class MockRoutinesProvider extends _i1.Mock implements _i12.RoutinesProvider {
#handleConfig,
[
entry,
input,
value,
type,
],
),

View File

@@ -692,7 +692,7 @@ class MockRoutinesProvider extends _i1.Mock implements _i12.RoutinesProvider {
@override
_i13.Future<void> handleConfig(
_i8.SlotEntry? entry,
String? input,
num? value,
_i8.ConfigType? type,
) =>
(super.noSuchMethod(
@@ -700,7 +700,7 @@ class MockRoutinesProvider extends _i1.Mock implements _i12.RoutinesProvider {
#handleConfig,
[
entry,
input,
value,
type,
],
),

View File

@@ -692,7 +692,7 @@ class MockRoutinesProvider extends _i1.Mock implements _i12.RoutinesProvider {
@override
_i13.Future<void> handleConfig(
_i8.SlotEntry? entry,
String? input,
num? value,
_i8.ConfigType? type,
) =>
(super.noSuchMethod(
@@ -700,7 +700,7 @@ class MockRoutinesProvider extends _i1.Mock implements _i12.RoutinesProvider {
#handleConfig,
[
entry,
input,
value,
type,
],
),

View File

@@ -692,7 +692,7 @@ class MockRoutinesProvider extends _i1.Mock implements _i12.RoutinesProvider {
@override
_i13.Future<void> handleConfig(
_i8.SlotEntry? entry,
String? input,
num? value,
_i8.ConfigType? type,
) =>
(super.noSuchMethod(
@@ -700,7 +700,7 @@ class MockRoutinesProvider extends _i1.Mock implements _i12.RoutinesProvider {
#handleConfig,
[
entry,
input,
value,
type,
],
),

View File

@@ -692,7 +692,7 @@ class MockRoutinesProvider extends _i1.Mock implements _i12.RoutinesProvider {
@override
_i13.Future<void> handleConfig(
_i8.SlotEntry? entry,
String? input,
num? value,
_i8.ConfigType? type,
) =>
(super.noSuchMethod(
@@ -700,7 +700,7 @@ class MockRoutinesProvider extends _i1.Mock implements _i12.RoutinesProvider {
#handleConfig,
[
entry,
input,
value,
type,
],
),

View File

@@ -692,7 +692,7 @@ class MockRoutinesProvider extends _i1.Mock implements _i12.RoutinesProvider {
@override
_i13.Future<void> handleConfig(
_i8.SlotEntry? entry,
String? input,
num? value,
_i8.ConfigType? type,
) =>
(super.noSuchMethod(
@@ -700,7 +700,7 @@ class MockRoutinesProvider extends _i1.Mock implements _i12.RoutinesProvider {
#handleConfig,
[
entry,
input,
value,
type,
],
),

View File

@@ -692,7 +692,7 @@ class MockRoutinesProvider extends _i1.Mock implements _i12.RoutinesProvider {
@override
_i13.Future<void> handleConfig(
_i8.SlotEntry? entry,
String? input,
num? value,
_i8.ConfigType? type,
) =>
(super.noSuchMethod(
@@ -700,7 +700,7 @@ class MockRoutinesProvider extends _i1.Mock implements _i12.RoutinesProvider {
#handleConfig,
[
entry,
input,
value,
type,
],
),