Skip to content

Fix: Improve Manual Decimal Input Precision and Use SingleActivator #99

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## [0.13.2] - 2025-03-21

* Fix decimal value handling
* Fix dart analyzer warnings

## [0.13.1] - 2023-05-11

* Fixed `onSubmitted` behavior and public State classes (#86)
Expand Down
8 changes: 4 additions & 4 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,14 @@ class VerticalSpinBoxPage extends StatelessWidget {
textStyle: TextStyle(fontSize: 48),
incrementIcon: Icon(Icons.keyboard_arrow_up, size: 64),
decrementIcon: Icon(Icons.keyboard_arrow_down, size: 64),
iconColor: MaterialStateProperty.resolveWith((states) {
if (states.contains(MaterialState.disabled)) {
iconColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) {
return Colors.grey;
}
if (states.contains(MaterialState.error)) {
if (states.contains(WidgetState.error)) {
return Colors.red;
}
if (states.contains(MaterialState.focused)) {
if (states.contains(WidgetState.focused)) {
return Colors.blue;
}
return Colors.black;
Expand Down
86 changes: 86 additions & 0 deletions example/lib/test_spinbox_improvements.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:flutter_spinbox/material.dart';

void main() {
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'SpinBox Improvements Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'SpinBox Improvements Demo'),
);
}
}

class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);

final String title;

@override
State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
double _integerValue = 5;
double _decimalValue = 5.5;
double _manyDecimalsValue = 5.0;

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('SpinBox with integer values (decimals: 0):'),
SpinBox(
min: 0,
max: 10,
value: _integerValue,
decimals: 0,
onChanged: (value) => setState(() => _integerValue = value),
),
const SizedBox(height: 20),
const Text('SpinBox with decimal values (decimals: 2):'),
SpinBox(
min: 0,
max: 10,
value: _decimalValue,
decimals: 2,
step: 0.5,
onChanged: (value) => setState(() => _decimalValue = value),
),
const SizedBox(height: 20),
const Text('SpinBox with many decimals (decimals: 4):'),
SpinBox(
min: 0,
max: 10,
value: _manyDecimalsValue,
decimals: 4,
step: 0.1,
onChanged: (value) => setState(() => _manyDecimalsValue = value),
),
const SizedBox(height: 20),
Text('Current values:'),
Text('Integer: $_integerValue'),
Text('Decimal: $_decimalValue'),
Text('Many decimals: $_manyDecimalsValue'),
],
),
),
);
}
}
76 changes: 60 additions & 16 deletions lib/src/base_spin_box.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

import 'dart:math';

import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';

Expand Down Expand Up @@ -61,18 +63,34 @@ mixin SpinBoxMixin<T extends BaseSpinBox> on State<T> {

static double _parseValue(String text) => double.tryParse(text) ?? 0;
String _formatText(double value) {
return value.toStringAsFixed(widget.decimals).padLeft(widget.digits, '0');
// If decimals are 0 or the value has no decimal part, show as integer
if (widget.decimals <= 0 || value == value.truncateToDouble()) {
return value.toInt().toString().padLeft(widget.digits, '0');
}

// Format with decimals but remove trailing zeros
String formatted = value.toStringAsFixed(widget.decimals);
if (formatted.contains('.')) {
// Remove trailing zeros
while (formatted.endsWith('0')) {
formatted = formatted.substring(0, formatted.length - 1);
}
// Remove the decimal point if it's the last character
if (formatted.endsWith('.')) {
formatted = formatted.substring(0, formatted.length - 1);
}
}
return formatted.padLeft(widget.digits, '0');
}

Map<ShortcutActivator, VoidCallback> get bindings {
return {
// ### TODO: use SingleActivator fixed in Flutter 2.10+
// https://github.com/flutter/flutter/issues/92717
LogicalKeySet(LogicalKeyboardKey.arrowUp): _stepUp,
LogicalKeySet(LogicalKeyboardKey.arrowDown): _stepDown,
// Using SingleActivator as fixed in Flutter 2.10+
const SingleActivator(LogicalKeyboardKey.arrowUp): _stepUp,
const SingleActivator(LogicalKeyboardKey.arrowDown): _stepDown,
if (widget.pageStep != null) ...{
LogicalKeySet(LogicalKeyboardKey.pageUp): _pageStepUp,
LogicalKeySet(LogicalKeyboardKey.pageDown): _pageStepDown,
const SingleActivator(LogicalKeyboardKey.pageUp): _pageStepUp,
const SingleActivator(LogicalKeyboardKey.pageDown): _pageStepDown,
}
};
}
Expand Down Expand Up @@ -138,21 +156,36 @@ mixin SpinBoxMixin<T extends BaseSpinBox> on State<T> {
final oldOffset = value.isNegative ? 1 : 0;
final newOffset = _parseValue(text).isNegative ? 1 : 0;

_controller.value = _controller.value.copyWith(
text: text,
selection: selection.copyWith(
baseOffset: selection.baseOffset - oldOffset + newOffset,
extentOffset: selection.extentOffset - oldOffset + newOffset,
),
);
// Preserve cursor position when possible
final cursorPos = selection.baseOffset;
if (cursorPos >= 0 && cursorPos <= _controller.text.length) {
_controller.value = _controller.value.copyWith(
text: text,
selection: TextSelection.collapsed(
offset: min(cursorPos, text.length),
),
);
} else {
_controller.value = _controller.value.copyWith(
text: text,
selection: selection.copyWith(
baseOffset: selection.baseOffset - oldOffset + newOffset,
extentOffset: selection.extentOffset - oldOffset + newOffset,
),
);
}
}

@protected
double fixupValue(String value) {
final v = _parseValue(value);
if (value.isEmpty || (v < widget.min || v > widget.max)) {
// will trigger notify to _updateValue()
_controller.text = _formatText(_cachedValue);
final newText = _formatText(_cachedValue);
_controller.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(offset: newText.length),
);
} else {
_cachedValue = _value;
}
Expand Down Expand Up @@ -181,7 +214,18 @@ mixin SpinBoxMixin<T extends BaseSpinBox> on State<T> {
if (oldWidget.value != widget.value) {
_controller.removeListener(_updateValue);
_value = _cachedValue = widget.value;
_updateController(oldWidget.value, widget.value);

// When value is reset to 0 (default), ensure cursor is at the end
if (widget.value == 0 && oldWidget.value != 0) {
final text = _formatText(widget.value);
_controller.value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
);
} else {
_updateController(oldWidget.value, widget.value);
}

_controller.addListener(_updateValue);
}
}
Expand Down
20 changes: 10 additions & 10 deletions lib/src/material/spin_box.dart
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ class SpinBox extends BaseSpinBox {
///
/// If `null`, then the value of [SpinBoxThemeData.iconColor] is used. If
/// that is also `null`, then pre-defined defaults are used.
final MaterialStateProperty<Color?>? iconColor;
final WidgetStateProperty<Color?>? iconColor;

/// Whether the increment and decrement buttons are shown.
///
Expand Down Expand Up @@ -318,19 +318,19 @@ class SpinBoxState extends State<SpinBox> with SpinBoxMixin {

final iconColor = widget.iconColor ??
spinBoxTheme?.iconColor ??
MaterialStateProperty.all(_iconColor(theme, errorText));
WidgetStateProperty.all(_iconColor(theme, errorText));

final states = <MaterialState>{
if (!widget.enabled) MaterialState.disabled,
if (hasFocus) MaterialState.focused,
if (errorText != null) MaterialState.error,
final states = <WidgetState>{
if (!widget.enabled) WidgetState.disabled,
if (hasFocus) WidgetState.focused,
if (errorText != null) WidgetState.error,
};

final decrementStates = Set<MaterialState>.of(states);
if (value <= widget.min) decrementStates.add(MaterialState.disabled);
final decrementStates = Set<WidgetState>.of(states);
if (value <= widget.min) decrementStates.add(WidgetState.disabled);

final incrementStates = Set<MaterialState>.of(states);
if (value >= widget.max) incrementStates.add(MaterialState.disabled);
final incrementStates = Set<WidgetState>.of(states);
if (value >= widget.max) incrementStates.add(WidgetState.disabled);

var bottom = 0.0;
final isHorizontal = widget.direction == Axis.horizontal;
Expand Down
12 changes: 6 additions & 6 deletions lib/src/material/spin_box_theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ class SpinBoxThemeData with Diagnosticable {
/// The color to use for [SpinBox.incrementIcon] and [SpinBox.decrementIcon].
///
/// Resolves in the following states:
/// * [MaterialState.focused].
/// * [MaterialState.disabled].
/// * [MaterialState.error].
/// * [WidgetState.focused].
/// * [WidgetState.disabled].
/// * [WidgetState.error].
///
/// If specified, overrides the default value of [SpinBox.iconColor].
final MaterialStateProperty<Color?>? iconColor;
final WidgetStateProperty<Color?>? iconColor;

/// See [TextField.decoration].
///
Expand All @@ -39,7 +39,7 @@ class SpinBoxThemeData with Diagnosticable {
/// new values.
SpinBoxThemeData copyWith({
double? iconSize,
MaterialStateProperty<Color?>? iconColor,
WidgetStateProperty<Color?>? iconColor,
InputDecoration? decoration,
}) {
return SpinBoxThemeData(
Expand Down Expand Up @@ -73,7 +73,7 @@ class SpinBoxThemeData with Diagnosticable {
),
);
properties.add(
DiagnosticsProperty<MaterialStateProperty<Color?>>(
DiagnosticsProperty<WidgetStateProperty<Color?>>(
'iconColor',
iconColor,
defaultValue: null,
Expand Down
48 changes: 43 additions & 5 deletions lib/src/spin_formatter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,31 +39,67 @@ class SpinFormatter extends TextInputFormatter {
return newValue;
}

// Allow only negative sign at the start
final minus = input.startsWith('-');
if (minus && min >= 0) {
return oldValue;
}

// Allow only positive sign at the start
final plus = input.startsWith('+');
if (plus && max < 0) {
return oldValue;
}

// Allow only the sign
if ((minus || plus) && input.length == 1) {
return newValue;
}

if (decimals <= 0 && !_validateValue(int.tryParse(input))) {
return oldValue;
// Allow only a decimal point
if (input == '.' || input == '-.' || input == '+.') {
return TextEditingValue(
text: input == '.' ? '0.' : (input == '-.' ? '-0.' : '+0.'),
selection: TextSelection.collapsed(offset: input.length + 1),
);
}

// Verify if it's a valid number
bool isValidNumber = false;
num? parsedValue;

if (decimals <= 0) {
parsedValue = int.tryParse(input);
isValidNumber = _validateValue(parsedValue);
} else {
// Allow partial decimal entry
if (input.endsWith('.')) {
// Allow ending with decimal point
String valueToCheck = input.substring(0, input.length - 1);
if (valueToCheck.isEmpty ||
valueToCheck == '-' ||
valueToCheck == '+') {
valueToCheck = '${valueToCheck}0';
}
parsedValue = double.tryParse(valueToCheck);
isValidNumber = _validateValue(parsedValue);
} else {
parsedValue = double.tryParse(input);
isValidNumber = _validateValue(parsedValue);
}
}

if (decimals > 0 && !_validateValue(double.tryParse(input))) {
if (!isValidNumber) {
return oldValue;
}

// Verify number of decimals
final dot = input.lastIndexOf('.');
if (dot >= 0 && decimals < input.substring(dot + 1).length) {
return oldValue;
if (dot >= 0) {
final decimalPart = input.substring(dot + 1);
if (decimals < decimalPart.length) {
return oldValue;
}
}

return newValue;
Expand All @@ -74,10 +110,12 @@ class SpinFormatter extends TextInputFormatter {
return false;
}

// If the value is within the range, it is valid
if (value >= min && value <= max) {
return true;
}

// Allow partial values during editing
if (value >= 0) {
return value <= max;
} else {
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: flutter_spinbox
description: >-
SpinBox is a numeric input widget with an input field for entering a specific value,
and spin buttons for quick, convenient, and accurate value adjustments.
version: 0.13.1
version: 0.13.2
homepage: https://github.com/jpnurmi/flutter_spinbox
repository: https://github.com/jpnurmi/flutter_spinbox
issue_tracker: https://github.com/jpnurmi/flutter_spinbox/issues
Expand Down
Loading