From bff4a25f5fe7bdbb56ba5f9cfba0100ec0883dd4 Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Mon, 18 Nov 2024 12:39:21 +0100 Subject: [PATCH] update time picker --- CHANGELOG.md | 2 +- example/lib/pages/time_picker.dart | 6 +- lib/src/components/input.dart | 1 + lib/src/components/time_picker.dart | 407 +++++++++++++++++++++++++--- 4 files changed, 371 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92bc008f..2af37201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## 0.16.0 - **FEAT**: New `ShadTimePicker` and `ShadDatePickerFormField` components. -- **FIX**: `maxLength` and `maxLengthEnforcement` not working on `ShadInput` +- **FIX**: `maxLength`, `maxLengthEnforcement` and `showCursor` not working on `ShadInput` - **CHORE**: Set minimum Flutter version to `3.24.0` - **CHORE**: Remove `trackColor` from `ShadSwitch` (thanks to @RaghavTheGreat) diff --git a/example/lib/pages/time_picker.dart b/example/lib/pages/time_picker.dart index 24672d32..63de876a 100644 --- a/example/lib/pages/time_picker.dart +++ b/example/lib/pages/time_picker.dart @@ -13,7 +13,11 @@ class TimePickerPage extends StatelessWidget { children: [ ConstrainedBox( constraints: const BoxConstraints(maxWidth: 600), - child: const ShadTimePicker(), + child: ShadTimePicker( + onChanged: (time) { + print('time: $time'); + }, + ), ), ], ); diff --git a/lib/src/components/input.dart b/lib/src/components/input.dart index 78916d1b..30230396 100644 --- a/lib/src/components/input.dart +++ b/lib/src/components/input.dart @@ -550,6 +550,7 @@ class ShadInputState extends State textAlign: widget.textAlign, onTapOutside: widget.onPressedOutside, rendererIgnoresPointer: true, + showCursor: widget.showCursor, ), ), ), diff --git a/lib/src/components/time_picker.dart b/lib/src/components/time_picker.dart index 61df206f..88158db6 100644 --- a/lib/src/components/time_picker.dart +++ b/lib/src/components/time_picker.dart @@ -1,54 +1,221 @@ +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; import 'package:shadcn_ui/src/components/input.dart'; import 'package:shadcn_ui/src/theme/theme.dart'; import 'package:shadcn_ui/src/utils/separated_iterable.dart'; +@immutable +class ShadTimeOfDay extends TimeOfDay { + /// Creates a time of day. + /// + /// The [hour] argument must be between 0 and 23, inclusive. The [minute] + /// argument must be between 0 and 59, inclusive. The [second] argument must + /// be between 0 and 59, inclusive. + const ShadTimeOfDay({ + required super.hour, + required super.minute, + required this.second, + }); + + /// Creates a time of day based on the given time. + /// + /// The [hour] is set to the time's hour and the [minute] is set to the time's + /// minute in the timezone of the given [DateTime]. + ShadTimeOfDay.fromDateTime(DateTime time) + : second = time.second, + super(hour: time.hour, minute: time.minute); + + /// Creates a time of day based on the current time. + /// + /// The [hour] is set to the current hour, the [minute] is set to the + /// current minute in the local time zone and the [second] is set to the + /// current second. + ShadTimeOfDay.now() : this.fromDateTime(DateTime.now()); + + /// The selected second. + final int second; + + @override + bool operator ==(Object other) { + return other is ShadTimeOfDay && + other.hour == hour && + other.minute == minute && + other.second == second; + } + + @override + int get hashCode => Object.hash(hour, minute, second); + + @override + String toString() { + String addLeadingZeroIfNeeded(int value) { + return value.toString().padLeft(2, '0'); + } + + final hourLabel = addLeadingZeroIfNeeded(hour); + final minuteLabel = addLeadingZeroIfNeeded(minute); + final secondLabel = addLeadingZeroIfNeeded(second); + + return '$TimeOfDay($hourLabel:$minuteLabel:$secondLabel)'; + } +} + class ShadTimePicker extends StatefulWidget { const ShadTimePicker({ super.key, this.axis, this.gap, + this.jumpToNextFieldWhenFilled, + this.onChanged, + this.initialValue, + this.hourLabel, + this.minuteLabel, + this.secondLabel, }); + /// {@template ShadTimePicker.axis} + /// The axis along which the fields are laid out. Defaults to `horizontal`. + /// {@endtemplate} final Axis? axis; + + /// {@template ShadTimePicker.gap} + /// The gap between the fields in the picker. Defaults to `0`. + /// {@endtemplate} final double? gap; + /// {@template ShadTimePicker.jumpToNextFieldWhenFilled} + /// Whether the focus should jump to the next field when the current field is + /// filled. Defaults to `true`. + /// {@endtemplate} + final bool? jumpToNextFieldWhenFilled; + + /// {@template ShadTimePicker.onChanged} + /// The callback that is called when the selected time changes. + /// {@endtemplate} + final ValueChanged? onChanged; + + /// {@template ShadTimePicker.initialValue} + /// The initial time to show in the picker, defaults to null. + /// {@endtemplate} + final ShadTimeOfDay? initialValue; + + /// {@template ShadTimePicker.hourLabel} + /// The widget to display as the label for the hour field. + /// {@endtemplate} + final Widget? hourLabel; + + /// {@template ShadTimePicker.minuteLabel} + /// The widget to display as the label for the minute field. + /// {@endtemplate} + final Widget? minuteLabel; + + /// {@template ShadTimePicker.secondLabel} + /// The widget to display as the label for the second field. + /// {@endtemplate} + final Widget? secondLabel; + @override State createState() => _ShadTimePickerState(); } class _ShadTimePickerState extends State { + final focusNodes = [FocusNode(), FocusNode(), FocusNode()]; + late final List controllers; + late final Listenable listenable; + + @override + void initState() { + super.initState(); + controllers = [ + ShadTimePickerTextEditingController( + max: 23, + text: widget.initialValue?.hour.toString().padLeft(2, '0'), + ), + ShadTimePickerTextEditingController( + text: widget.initialValue?.minute.toString().padLeft(2, '0'), + ), + ShadTimePickerTextEditingController( + text: widget.initialValue?.second.toString().padLeft(2, '0'), + ), + ]; + listenable = Listenable.merge(controllers); + listenable.addListener(onChanged); + } + + @override + void dispose() { + listenable.removeListener(onChanged); + for (final node in focusNodes) { + node.dispose(); + } + focusNodes.clear(); + for (final controller in controllers) { + controller.dispose(); + } + controllers.clear(); + super.dispose(); + } + + void onChanged() { + final hour = controllers[0].text; + final minute = controllers[1].text; + final second = controllers[2].text; + + if (hour.length == 2 && minute.length == 2 && second.length == 2) { + widget.onChanged?.call( + ShadTimeOfDay( + hour: int.parse(hour), + minute: int.parse(minute), + second: int.parse(second), + ), + ); + } + } + @override Widget build(BuildContext context) { final effectiveAxis = widget.axis ?? Axis.horizontal; final effectiveGap = widget.gap ?? 0; + final effectiveJumpToNextField = widget.jumpToNextFieldWhenFilled ?? true; + final effectiveHourLabel = widget.hourLabel ?? const Text('Hours'); + final effectiveMinuteLabel = widget.minuteLabel ?? const Text('Minutes'); + final effectiveSecondLabel = widget.secondLabel ?? const Text('Seconds'); return Flex( mainAxisSize: MainAxisSize.min, direction: effectiveAxis, children: [ - const Flexible( + Flexible( child: ShadTimePickerField( - label: Text('Hours'), - min: 0, - max: 23, - placeholder: Text('00'), + focusNode: focusNodes[0], + label: effectiveHourLabel, + controller: controllers[0], + placeholder: const Text('00'), + onChanged: (v) { + if (effectiveJumpToNextField && v.length == 2) { + focusNodes[1].requestFocus(); + } + }, ), ), - const Flexible( + Flexible( child: ShadTimePickerField( - label: Text('Minutes'), - min: 0, - max: 59, - placeholder: Text('00'), + focusNode: focusNodes[1], + label: effectiveMinuteLabel, + controller: controllers[1], + placeholder: const Text('00'), + onChanged: (v) { + if (effectiveJumpToNextField && v.length == 2) { + focusNodes[2].requestFocus(); + } + }, ), ), - const Flexible( + Flexible( child: ShadTimePickerField( - label: Text('Seconds'), - min: 0, - max: 59, - placeholder: Text('00'), + focusNode: focusNodes[2], + label: effectiveSecondLabel, + controller: controllers[2], + placeholder: const Text('00'), ), ), ].separatedBy(SizedBox.square(dimension: effectiveGap)), @@ -60,33 +227,36 @@ class ShadTimePickerField extends StatefulWidget { const ShadTimePickerField({ super.key, this.label, - this.min, - this.max, this.placeholder, this.controller, this.gap, + this.style, + this.onChanged, + this.focusNode, }); final Widget? label; - final int? min; - final int? max; final Widget? placeholder; - final TextEditingController? controller; + final ShadTimePickerTextEditingController? controller; final double? gap; + final TextStyle? style; + final ValueChanged? onChanged; + final FocusNode? focusNode; @override State createState() => _ShadTimePickerFieldState(); } class _ShadTimePickerFieldState extends State { - TextEditingController? _controller; - TextEditingController get controller => widget.controller ?? _controller!; + ShadTimePickerTextEditingController? _controller; + ShadTimePickerTextEditingController get controller => + widget.controller ?? _controller!; @override void initState() { super.initState(); if (widget.controller == null) { - _controller = TextEditingController(); + _controller = ShadTimePickerTextEditingController(); } } @@ -99,9 +269,23 @@ class _ShadTimePickerFieldState extends State { @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); - final effectiveMin = widget.min ?? 0; - final effectiveMax = widget.max ?? 59; final effectiveGap = widget.gap ?? 2; + + final defaultStyle = theme.textTheme.muted.copyWith( + color: theme.colorScheme.foreground, + fontSize: 16, + height: 24 / 16, + ); + + final effectiveStyle = defaultStyle.merge(widget.style); + + final defaultPlaceholderStyle = theme.textTheme.muted.copyWith( + fontSize: 16, + height: 24 / 16, + ); + final effectivePlaceholderStyle = + defaultPlaceholderStyle.merge(controller.placeholderStyle); + return Column( children: [ if (widget.label != null) @@ -114,33 +298,22 @@ class _ShadTimePickerFieldState extends State { SizedBox( width: 58, child: ShadInput( - style: theme.textTheme.muted.copyWith( - color: theme.colorScheme.foreground, - fontSize: 16, - height: 24 / 16, - ), - placeholderStyle: theme.textTheme.muted.copyWith( - fontSize: 16, - height: 24 / 16, - ), + focusNode: widget.focusNode, + style: effectiveStyle, + placeholderStyle: effectivePlaceholderStyle, controller: controller, placeholder: widget.placeholder, keyboardType: TextInputType.number, textInputAction: TextInputAction.next, maxLength: 2, + showCursor: false, + maxLengthEnforcement: MaxLengthEnforcement.none, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), inputFormatters: [ FilteringTextInputFormatter.digitsOnly, ], onChanged: (value) { - final intValue = int.tryParse(value); - if (intValue == null) return; - if (intValue < effectiveMin) { - controller.text = effectiveMin.toString().padLeft(2, '0'); - } - if (intValue > effectiveMax) { - controller.text = effectiveMax.toString().padLeft(2, '0'); - } + widget.onChanged?.call(value); }, ), ), @@ -148,3 +321,151 @@ class _ShadTimePickerFieldState extends State { ); } } + +class ShadTimePickerTextEditingController extends TextEditingController { + ShadTimePickerTextEditingController({ + super.text, + this.placeholderStyle, + this.min = 0, + this.max = 59, + }); + + ShadTimePickerTextEditingController.fromValue( + TextEditingValue? value, { + this.placeholderStyle, + this.min = 0, + this.max = 59, + }) : assert( + value == null || + !value.composing.isValid || + value.isComposingRangeValid, + ''' + New TextEditingValue $value has an invalid non-empty composing range + ${value.composing}. It is recommended to use a valid composing range, + even for readonly text fields.''', + ), + super.fromValue(value ?? TextEditingValue.empty); + + final TextStyle? placeholderStyle; + final int min; + final int max; + + @override + TextSpan buildTextSpan({ + required BuildContext context, + TextStyle? style, + required bool withComposing, + }) { + assert( + !value.composing.isValid || !withComposing || value.isComposingRangeValid, + ); + // If the composing range is out of range for the current text, ignore it to + // preserve the tree integrity, otherwise in release mode a RangeError will + // be thrown and this EditableText will be built with a broken subtree. + final composingRegionOutOfRange = + !value.isComposingRangeValid || !withComposing; + + final theme = ShadTheme.of(context); + + final defaultPlaceholderStyle = theme.textTheme.muted.copyWith( + fontSize: 16, + height: 24 / 16, + ); + final effectivePlaceholderStyle = + defaultPlaceholderStyle.merge(placeholderStyle); + + final intValue = int.tryParse(value.text); + if (intValue == null) return const TextSpan(); + + if (composingRegionOutOfRange) { + return TextSpan( + style: style, + children: [ + if (value.text.length == 1 && intValue < 10) + TextSpan(text: '0', style: effectivePlaceholderStyle), + TextSpan(text: text), + ], + ); + } + + final composingStyle = + style?.merge(const TextStyle(decoration: TextDecoration.underline)) ?? + const TextStyle(decoration: TextDecoration.underline); + return TextSpan( + style: style, + children: [ + TextSpan(text: value.composing.textBefore(value.text)), + TextSpan( + style: composingStyle, + text: value.composing.textInside(value.text), + ), + TextSpan(text: value.composing.textAfter(value.text)), + ], + ); + } + + /// Setting this will notify all the listeners of this [TextEditingController] + /// that they need to update (it calls [notifyListeners]). For this reason, + /// this value should only be set between frames, e.g. in response to user + /// actions, not during the build, layout, or paint phases. + /// + /// This property can be set from a listener added to this + /// [TextEditingController]; **however, one should not also set [selection] + /// in a separate statement. To change both the [text] and the [selection] + /// change the controller's [value].** Setting this here will clear + /// the current selection and composing range, so avoid using it directly + /// unless that is the desired behavior. + @override + set text(String newText) { + final effectiveText = adjustText(newText); + + value = value.copyWith( + text: effectiveText, + selection: const TextSelection.collapsed(offset: -1), + composing: TextRange.empty, + ); + } + + @override + set value(TextEditingValue newValue) { + assert( + !newValue.composing.isValid || newValue.isComposingRangeValid, + 'New TextEditingValue $newValue has an invalid non-empty composing range ' + '${newValue.composing}. It is recommended to use a valid composing range,' + ' even for readonly text fields.', + ); + final newText = adjustText(newValue.text); + // super.value = newValue; + super.value = TextEditingValue( + text: adjustText(newValue.text), + selection: TextSelection.collapsed(offset: newText.length), + ); + } + + String adjustText(String newText) { + var effectiveText = newText; + if (effectiveText.length == 3) { + effectiveText = effectiveText.substring(2); + } + + final intValue = int.tryParse(effectiveText); + if (intValue == null) return ''; + if (intValue < min) return min.toString(); + if (intValue > max) return max.toString(); + return effectiveText; + } + + ShadTimePickerTextEditingController copyWith({ + String? text, + int? min, + int? max, + TextStyle? placeholderStyle, + }) { + return ShadTimePickerTextEditingController( + text: text ?? this.text, + min: min ?? this.min, + max: max ?? this.max, + placeholderStyle: placeholderStyle ?? this.placeholderStyle, + ); + } +}