From bf7028f92344d701c9022e29b33be89f7fbe3cbf Mon Sep 17 00:00:00 2001 From: Alexandru Mariuti Date: Wed, 27 Nov 2024 11:57:53 +0100 Subject: [PATCH] feat/time-picker (#195) --- CHANGELOG.md | 6 +- README.md | 1 + docs/src/content/docs/Components/form.mdx | 1 + .../content/docs/Components/time-picker.mdx | 64 + example/lib/main.dart | 4 + example/lib/pages/time_picker.dart | 37 + example/lib/pages/time_picker_form_field.dart | 101 ++ lib/shadcn_ui.dart | 3 + .../components/form/fields/time_picker.dart | 513 ++++++++ lib/src/components/input.dart | 17 +- lib/src/components/time_picker.dart | 1027 +++++++++++++++++ lib/src/theme/components/time_picker.dart | 237 ++++ lib/src/theme/data.dart | 15 +- lib/src/theme/themes/base.dart | 4 + ...ult_theme_no_secondary_border_variant.dart | 51 +- .../theme/themes/default_theme_variant.dart | 35 + playground/analysis_options.yaml | 4 +- playground/lib/main.dart | 38 +- playground/lib/pages/form.dart | 17 + playground/lib/pages/time_picker.dart | 56 + playground/pubspec.yaml | 1 + pubspec.yaml | 2 +- 22 files changed, 2226 insertions(+), 8 deletions(-) create mode 100644 docs/src/content/docs/Components/time-picker.mdx create mode 100644 example/lib/pages/time_picker.dart create mode 100644 example/lib/pages/time_picker_form_field.dart create mode 100644 lib/src/components/form/fields/time_picker.dart create mode 100644 lib/src/components/time_picker.dart create mode 100644 lib/src/theme/components/time_picker.dart create mode 100644 playground/lib/pages/time_picker.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 7657972a..924f7cd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,11 @@ -## 0.15.4 +## 0.16.0 +- **FEAT**: New `ShadTimePicker` and `ShadDatePickerFormField` components. +- **FIX**: `maxLength`, `maxLengthEnforcement` and `showCursor` not working on `ShadInput` +- **FIX**: `ShadCalendar` range day button text style when `disableSecondaryBorder` is `true`. - **CHORE**: Set minimum Flutter version to `3.24.0` - **CHORE**: Remove `trackColor` from `ShadSwitch` (thanks to @RaghavTheGreat) +- **FIX**: `ShadSlider` `onChanged` called on every controller update (thanks to @helightdev). ## 0.15.3 diff --git a/README.md b/README.md index bd4ea91a..4c8c4f28 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ See the [documentation](https://flutter-shadcn-ui.mariuti.com/) to interact with - [x] [Table](https://flutter-shadcn-ui.mariuti.com/components/table/) - [x] [Tabs](https://flutter-shadcn-ui.mariuti.com/components/tabs/) - [ ] TextArea +- [x] [Time Picker](https://flutter-shadcn-ui.mariuti.com/components/time-picker/) - [x] [Toast](https://flutter-shadcn-ui.mariuti.com/components/toast/) - [ ] Toggle - [ ] ToggleGroup diff --git a/docs/src/content/docs/Components/form.mdx b/docs/src/content/docs/Components/form.mdx index 91883f89..9db567b7 100644 --- a/docs/src/content/docs/Components/form.mdx +++ b/docs/src/content/docs/Components/form.mdx @@ -81,3 +81,4 @@ See the following links for more examples on how to use the `ShadForm` component - [Select](../select#form) - [RadioGroup](../radio-group#form) - [DatePicker](../date-picker#form) +- [TimePicker](../time-picker#form) diff --git a/docs/src/content/docs/Components/time-picker.mdx b/docs/src/content/docs/Components/time-picker.mdx new file mode 100644 index 00000000..62c6838f --- /dev/null +++ b/docs/src/content/docs/Components/time-picker.mdx @@ -0,0 +1,64 @@ +--- +title: Time Picker +sidebar: + order: 4 +--- + +import Preview from "../../../components/Preview.astro"; + +A time picker component. + + + +```dart +class PrimaryTimePicker extends StatelessWidget { + const PrimaryTimePicker({super.key}); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: const ShadTimePicker( + trailing: Padding( + padding: EdgeInsets.only(left: 8, top: 14), + child: ShadImage.square(LucideIcons.clock4, size: 16), + ), + ), + ); + } +} +``` + + + +## Form + + + +```dart +ShadTimePickerFormField( + label: const Text('Pick a time'), + onChanged: print, + description: + const Text('The time of the day you want to pick'), + validator: (v) => v == null ? 'A time is required' : null, +) +``` + + + +## ShadTimePickerFormField.period + + + +```dart +ShadTimePickerFormField.period( + label: const Text('Pick a time'), + onChanged: print, + description: + const Text('The time of the day you want to pick'), + validator: (v) => v == null ? 'A time is required' : null, +), +``` + + diff --git a/example/lib/main.dart b/example/lib/main.dart index d32e7197..5099f3c5 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -28,6 +28,8 @@ import 'package:example/pages/switch.dart'; import 'package:example/pages/switch_form_field.dart'; import 'package:example/pages/tabs.dart'; import 'package:example/pages/table.dart'; +import 'package:example/pages/time_picker.dart'; +import 'package:example/pages/time_picker_form_field.dart'; import 'package:example/pages/toast.dart'; import 'package:example/pages/tooltip.dart'; import 'package:example/pages/typography.dart'; @@ -72,6 +74,8 @@ final routes = { '/switch-form-field': (_) => const SwitchFormFieldPage(), '/table': (_) => const TablePage(), '/tabs': (_) => const TabsPage(), + '/time-picker': (_) => const TimePickerPage(), + '/time-picker-form-field': (_) => const TimePickerFormFieldPage(), '/toast': (_) => const ToastPage(), '/tooltip': (_) => const TooltipPage(), '/typography': (_) => const TypographyPage(), diff --git a/example/lib/pages/time_picker.dart b/example/lib/pages/time_picker.dart new file mode 100644 index 00000000..8cbdae70 --- /dev/null +++ b/example/lib/pages/time_picker.dart @@ -0,0 +1,37 @@ +import 'package:example/common/base_scaffold.dart'; +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class TimePickerPage extends StatelessWidget { + const TimePickerPage({super.key}); + + @override + Widget build(BuildContext context) { + return BaseScaffold( + appBarTitle: 'TimePicker', + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: ShadTimePicker( + trailing: const Padding( + padding: EdgeInsets.only(left: 8, top: 14), + child: ShadImage.square(LucideIcons.clock4, size: 16), + ), + onChanged: (time) { + print('time: $time'); + }, + ), + ), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: ShadTimePicker.period( + crossAxisAlignment: WrapCrossAlignment.end, + onChanged: (time) { + print('time: $time'); + }, + ), + ), + ], + ); + } +} diff --git a/example/lib/pages/time_picker_form_field.dart b/example/lib/pages/time_picker_form_field.dart new file mode 100644 index 00000000..d3431d54 --- /dev/null +++ b/example/lib/pages/time_picker_form_field.dart @@ -0,0 +1,101 @@ +// ignore_for_file: avoid_print + +import 'dart:convert'; + +import 'package:example/common/base_scaffold.dart'; +import 'package:example/common/properties/bool_property.dart'; +import 'package:example/common/properties/enum_property.dart'; +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class TimePickerFormFieldPage extends StatefulWidget { + const TimePickerFormFieldPage({super.key}); + + @override + State createState() => + _TimePickerFormFieldPageState(); +} + +class _TimePickerFormFieldPageState extends State { + bool enabled = true; + var autovalidateMode = ShadAutovalidateMode.alwaysAfterFirstValidation; + Map formValue = {}; + final formKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return ShadForm( + key: formKey, + enabled: enabled, + autovalidateMode: autovalidateMode, + child: BaseScaffold( + appBarTitle: 'TimePickerFormField', + editable: [ + MyBoolProperty( + label: 'Enabled', + value: enabled, + onChanged: (value) => setState(() => enabled = value), + ), + MyEnumProperty( + label: 'autovalidateMode', + value: autovalidateMode, + values: ShadAutovalidateMode.values, + onChanged: (value) { + if (value != null) { + setState(() => autovalidateMode = value); + } + }, + ), + ], + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 350), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadTimePickerFormField( + id: 'time', + label: const Text('Pick a time'), + onChanged: print, + description: + const Text('The time of the day you want to pick'), + validator: (v) => v == null ? 'A time is required' : null, + ), + const SizedBox(height: 16), + ShadButton( + child: const Text('Submit'), + onPressed: () { + print('submitted'); + if (formKey.currentState!.saveAndValidate()) { + setState(() { + formValue = formKey.currentState!.value; + }); + } else { + print('validation failed'); + } + }, + ), + if (formValue.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 24, left: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('FormValue', style: theme.textTheme.p), + const SizedBox(height: 4), + SelectableText( + formValue.toString(), + style: theme.textTheme.small, + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/shadcn_ui.dart b/lib/shadcn_ui.dart index 5d7dc3e8..dd2f0784 100644 --- a/lib/shadcn_ui.dart +++ b/lib/shadcn_ui.dart @@ -23,6 +23,7 @@ export 'src/components/form/fields/input.dart'; export 'src/components/form/fields/radio.dart'; export 'src/components/form/fields/select.dart'; export 'src/components/form/fields/switch.dart'; +export 'src/components/form/fields/time_picker.dart'; export 'src/components/form/form.dart'; export 'src/components/image.dart'; export 'src/components/input.dart'; @@ -36,6 +37,7 @@ export 'src/components/slider.dart'; export 'src/components/switch.dart'; export 'src/components/table.dart'; export 'src/components/tabs.dart'; +export 'src/components/time_picker.dart'; export 'src/components/toast.dart'; export 'src/components/tooltip.dart'; @@ -88,6 +90,7 @@ export 'src/theme/components/slider.dart'; export 'src/theme/components/switch.dart'; export 'src/theme/components/table.dart'; export 'src/theme/components/tabs.dart'; +export 'src/theme/components/time_picker.dart'; export 'src/theme/components/toast.dart'; export 'src/theme/components/tooltip.dart'; export 'src/theme/text_theme/text_styles_default.dart'; diff --git a/lib/src/components/form/fields/time_picker.dart b/lib/src/components/form/fields/time_picker.dart new file mode 100644 index 00000000..6dbc31d8 --- /dev/null +++ b/lib/src/components/form/fields/time_picker.dart @@ -0,0 +1,513 @@ +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/src/components/form/field.dart'; +import 'package:shadcn_ui/src/components/time_picker.dart'; +import 'package:shadcn_ui/src/theme/components/decorator.dart'; + +class ShadTimePickerFormField extends ShadFormBuilderField { + ShadTimePickerFormField({ + super.id, + super.key, + super.onSaved, + super.label, + super.error, + super.description, + super.valueTransformer, + super.onReset, + super.enabled, + super.autovalidateMode, + super.restorationId, + super.validator, + super.focusNode, + + /// {@macro ShadTimePicker.onChanged} + super.onChanged, + + /// {@macro ShadTimePicker.initialValue} + super.initialValue, + + /// @macro ShadTimePicker.controller + this.controller, + + /// {@macro ShadTimePicker.axis} + Axis? axis, + + /// {@macro ShadTimePicker.spacing} + double? spacing, + + /// {@macro ShadTimePicker.runSpacing} + double? runSpacing, + + /// {@macro ShadTimePicker.jumpToNextFieldWhenFilled} + bool? jumpToNextFieldWhenFilled, + + /// {@macro ShadTimePicker.hourLabel} + Widget? hourLabel, + + /// {@macro ShadTimePicker.minuteLabel} + Widget? minuteLabel, + + /// {@macro ShadTimePicker.secondLabel} + Widget? secondLabel, + + /// {@macro ShadTimePicker.hourPlaceholder} + Widget? hourPlaceholder, + + /// {@macro ShadTimePicker.minutePlaceholder} + Widget? minutePlaceholder, + + /// {@macro ShadTimePicker.secondPlaceholder} + Widget? secondPlaceholder, + + /// {@macro ShadTimePicker.leading} + Widget? leading, + + /// {@macro ShadTimePicker.trailing} + Widget? trailing, + + /// {@macro ShadTimePicker.alignment} + WrapAlignment? alignment, + + /// {@macro ShadTimePicker.runAlignment} + WrapAlignment? runAlignment, + + /// {@macro ShadTimePicker.crossAxisAlignment} + WrapCrossAlignment? crossAxisAlignment, + + /// {@macro ShadTimePicker.maxHour} + int maxHour = 23, + + /// {@macro ShadTimePicker.maxMinute} + int maxMinute = 59, + + /// {@macro ShadTimePicker.maxSecond} + int maxSecond = 59, + + /// {@macro ShadTimePicker.minHour} + int minHour = 0, + + /// {@macro ShadTimePicker.minMinute} + int minMinute = 0, + + /// {@macro ShadTimePicker.minSecond} + int minSecond = 0, + + /// {@macro ShadTimePicker.gap} + double? gap, + + /// {@macro ShadTimePicker.style} + TextStyle? style, + + /// {@macro ShadTimePicker.placeholderStyle} + TextStyle? placeholderStyle, + + /// {@macro ShadTimePicker.labelStyle} + TextStyle? labelStyle, + + /// {@macro ShadTimePicker.fieldWidth} + double? fieldWidth, + + /// {@macro ShadTimePicker.fieldPadding} + EdgeInsets? fieldPadding, + + /// {@macro ShadTimePicker.fieldDecoration} + ShadDecoration? fieldDecoration, + }) : super( + builder: (field) { + final state = field as _ShadFormBuilderTimePickerState; + return ShadTimePicker( + controller: state.controller, + onChanged: state.didChange, + enabled: state.enabled, + axis: axis, + spacing: spacing, + runSpacing: runSpacing, + jumpToNextFieldWhenFilled: jumpToNextFieldWhenFilled, + hourLabel: hourLabel, + minuteLabel: minuteLabel, + secondLabel: secondLabel, + hourPlaceholder: hourPlaceholder, + minutePlaceholder: minutePlaceholder, + secondPlaceholder: secondPlaceholder, + leading: leading, + trailing: trailing, + alignment: alignment, + runAlignment: runAlignment, + crossAxisAlignment: crossAxisAlignment, + maxHour: maxHour, + maxMinute: maxMinute, + maxSecond: maxSecond, + minHour: minHour, + minMinute: minMinute, + minSecond: minSecond, + gap: gap, + style: style, + placeholderStyle: placeholderStyle, + labelStyle: labelStyle, + fieldWidth: fieldWidth, + fieldPadding: fieldPadding, + fieldDecoration: fieldDecoration, + ); + }, + ); + + ShadTimePickerFormField.period({ + super.id, + super.key, + super.onSaved, + super.label, + super.error, + super.description, + super.valueTransformer, + super.onReset, + super.enabled, + super.autovalidateMode, + super.restorationId, + super.validator, + + /// {@macro ShadTimePicker.onChanged} + super.onChanged, + + /// {@macro ShadTimePicker.initialValue} + super.initialValue, + + /// @macro ShadTimePicker.controller + this.controller, + + /// {@macro ShadTimePicker.axis} + Axis? axis, + + /// {@macro ShadTimePicker.spacing} + double? spacing, + + /// {@macro ShadTimePicker.runSpacing} + double? runSpacing, + + /// {@macro ShadTimePicker.jumpToNextFieldWhenFilled} + bool? jumpToNextFieldWhenFilled, + + /// {@macro ShadTimePicker.hourLabel} + Widget? hourLabel, + + /// {@macro ShadTimePicker.minuteLabel} + Widget? minuteLabel, + + /// {@macro ShadTimePicker.secondLabel} + Widget? secondLabel, + + /// {@macro ShadTimePicker.hourPlaceholder} + Widget? hourPlaceholder, + + /// {@macro ShadTimePicker.minutePlaceholder} + Widget? minutePlaceholder, + + /// {@macro ShadTimePicker.secondPlaceholder} + Widget? secondPlaceholder, + + /// {@macro ShadTimePicker.leading} + Widget? leading, + + /// {@macro ShadTimePicker.trailing} + Widget? trailing, + + /// {@macro ShadTimePicker.alignment} + WrapAlignment? alignment, + + /// {@macro ShadTimePicker.runAlignment} + WrapAlignment? runAlignment, + + /// {@macro ShadTimePicker.crossAxisAlignment} + WrapCrossAlignment? crossAxisAlignment, + + /// {@macro ShadTimePicker.maxHour} + int maxHour = 23, + + /// {@macro ShadTimePicker.maxMinute} + int maxMinute = 59, + + /// {@macro ShadTimePicker.maxSecond} + int maxSecond = 59, + + /// {@macro ShadTimePicker.minHour} + int minHour = 0, + + /// {@macro ShadTimePicker.minMinute} + int minMinute = 0, + + /// {@macro ShadTimePicker.minSecond} + int minSecond = 0, + + /// {@macro ShadTimePicker.initialDayPeriod} + DayPeriod? initialDayPeriod, + + /// {@macro ShadTimePicker.periodLabel} + Widget? periodLabel, + + /// {@macro ShadTimePicker.periodPlaceholder} + Widget? periodPlaceholder, + + /// {@macro ShadTimePicker.periodHeight} + double? periodHeight, + + /// {@macro ShadTimePicker.periodMinWidth} + double? periodMinWidth, + + /// {@macro ShadTimePicker.gap} + double? gap, + + /// {@macro ShadTimePicker.style} + TextStyle? style, + + /// {@macro ShadTimePicker.placeholderStyle} + TextStyle? placeholderStyle, + + /// {@macro ShadTimePicker.labelStyle} + TextStyle? labelStyle, + + /// {@macro ShadTimePicker.fieldWidth} + double? fieldWidth, + + /// {@macro ShadTimePicker.fieldPadding} + EdgeInsets? fieldPadding, + + /// {@macro ShadTimePicker.fieldDecoration} + ShadDecoration? fieldDecoration, + + /// {@macro ShadTimePicker.periodDecoration} + ShadDecoration? periodDecoration, + }) : super( + builder: (field) { + final state = field as _ShadFormBuilderTimePickerState; + return ShadTimePicker.period( + controller: state.controller, + onChanged: state.didChange, + enabled: state.enabled, + axis: axis, + spacing: spacing, + runSpacing: runSpacing, + jumpToNextFieldWhenFilled: jumpToNextFieldWhenFilled, + hourLabel: hourLabel, + minuteLabel: minuteLabel, + secondLabel: secondLabel, + hourPlaceholder: hourPlaceholder, + minutePlaceholder: minutePlaceholder, + secondPlaceholder: secondPlaceholder, + leading: leading, + trailing: trailing, + alignment: alignment, + runAlignment: runAlignment, + crossAxisAlignment: crossAxisAlignment, + maxHour: maxHour, + maxMinute: maxMinute, + maxSecond: maxSecond, + minHour: minHour, + minMinute: minMinute, + minSecond: minSecond, + initialDayPeriod: initialDayPeriod, + periodLabel: periodLabel, + periodPlaceholder: periodPlaceholder, + periodHeight: periodHeight, + periodMinWidth: periodMinWidth, + gap: gap, + style: style, + placeholderStyle: placeholderStyle, + labelStyle: labelStyle, + fieldWidth: fieldWidth, + fieldPadding: fieldPadding, + fieldDecoration: fieldDecoration, + periodDecoration: periodDecoration, + ); + }, + ); + + ShadTimePickerFormField.raw({ + required ShadTimePickerVariant variant, + super.id, + super.key, + super.onSaved, + super.label, + super.error, + super.description, + super.valueTransformer, + super.onReset, + super.enabled, + super.autovalidateMode, + super.restorationId, + super.validator, + + /// {@macro ShadTimePicker.onChanged} + super.onChanged, + + /// {@macro ShadTimePicker.initialValue} + super.initialValue, + + /// @macro ShadTimePicker.controller + this.controller, + + /// {@macro ShadTimePicker.axis} + Axis? axis, + + /// {@macro ShadTimePicker.spacing} + double? spacing, + + /// {@macro ShadTimePicker.runSpacing} + double? runSpacing, + + /// {@macro ShadTimePicker.jumpToNextFieldWhenFilled} + bool? jumpToNextFieldWhenFilled, + + /// {@macro ShadTimePicker.hourLabel} + Widget? hourLabel, + + /// {@macro ShadTimePicker.minuteLabel} + Widget? minuteLabel, + + /// {@macro ShadTimePicker.secondLabel} + Widget? secondLabel, + + /// {@macro ShadTimePicker.hourPlaceholder} + Widget? hourPlaceholder, + + /// {@macro ShadTimePicker.minutePlaceholder} + Widget? minutePlaceholder, + + /// {@macro ShadTimePicker.secondPlaceholder} + Widget? secondPlaceholder, + + /// {@macro ShadTimePicker.leading} + Widget? leading, + + /// {@macro ShadTimePicker.trailing} + Widget? trailing, + + /// {@macro ShadTimePicker.alignment} + WrapAlignment? alignment, + + /// {@macro ShadTimePicker.runAlignment} + WrapAlignment? runAlignment, + + /// {@macro ShadTimePicker.crossAxisAlignment} + WrapCrossAlignment? crossAxisAlignment, + + /// {@macro ShadTimePicker.maxHour} + int maxHour = 23, + + /// {@macro ShadTimePicker.maxMinute} + int maxMinute = 59, + + /// {@macro ShadTimePicker.maxSecond} + int maxSecond = 59, + + /// {@macro ShadTimePicker.minHour} + int minHour = 0, + + /// {@macro ShadTimePicker.minMinute} + int minMinute = 0, + + /// {@macro ShadTimePicker.minSecond} + int minSecond = 0, + + /// {@macro ShadTimePicker.initialDayPeriod} + DayPeriod? initialDayPeriod, + + /// {@macro ShadTimePicker.periodLabel} + Widget? periodLabel, + + /// {@macro ShadTimePicker.periodPlaceholder} + Widget? periodPlaceholder, + + /// {@macro ShadTimePicker.periodHeight} + double? periodHeight, + + /// {@macro ShadTimePicker.periodMinWidth} + double? periodMinWidth, + + /// {@macro ShadTimePicker.gap} + double? gap, + + /// {@macro ShadTimePicker.style} + TextStyle? style, + + /// {@macro ShadTimePicker.placeholderStyle} + TextStyle? placeholderStyle, + + /// {@macro ShadTimePicker.labelStyle} + TextStyle? labelStyle, + + /// {@macro ShadTimePicker.fieldWidth} + double? fieldWidth, + + /// {@macro ShadTimePicker.fieldPadding} + EdgeInsets? fieldPadding, + + /// {@macro ShadTimePicker.fieldDecoration} + ShadDecoration? fieldDecoration, + + /// {@macro ShadTimePicker.periodDecoration} + ShadDecoration? periodDecoration, + }) : super( + builder: (field) { + final state = field as _ShadFormBuilderTimePickerState; + return ShadTimePicker.raw( + variant: variant, + controller: state.controller, + onChanged: state.didChange, + enabled: state.enabled, + axis: axis, + spacing: spacing, + runSpacing: runSpacing, + jumpToNextFieldWhenFilled: jumpToNextFieldWhenFilled, + hourLabel: hourLabel, + minuteLabel: minuteLabel, + secondLabel: secondLabel, + hourPlaceholder: hourPlaceholder, + minutePlaceholder: minutePlaceholder, + secondPlaceholder: secondPlaceholder, + leading: leading, + trailing: trailing, + alignment: alignment, + runAlignment: runAlignment, + crossAxisAlignment: crossAxisAlignment, + maxHour: maxHour, + maxMinute: maxMinute, + maxSecond: maxSecond, + minHour: minHour, + minMinute: minMinute, + minSecond: minSecond, + initialDayPeriod: initialDayPeriod, + periodLabel: periodLabel, + periodPlaceholder: periodPlaceholder, + periodHeight: periodHeight, + periodMinWidth: periodMinWidth, + gap: gap, + style: style, + placeholderStyle: placeholderStyle, + labelStyle: labelStyle, + fieldWidth: fieldWidth, + fieldPadding: fieldPadding, + fieldDecoration: fieldDecoration, + periodDecoration: periodDecoration, + ); + }, + ); + + @override + ShadFormBuilderFieldState + createState() => _ShadFormBuilderTimePickerState(); + + final ShadTimePickerController? controller; +} + +class _ShadFormBuilderTimePickerState + extends ShadFormBuilderFieldState { + // ignore: use_late_for_private_fields_and_variables + ShadTimePickerController? _controller; + ShadTimePickerController get controller => widget.controller ?? _controller!; + + @override + void initState() { + super.initState(); + if (widget.controller == null) { + _controller = ShadTimePickerController.fromTimeOfDay(widget.initialValue); + } + } +} diff --git a/lib/src/components/input.dart b/lib/src/components/input.dart index ad662803..30230396 100644 --- a/lib/src/components/input.dart +++ b/lib/src/components/input.dart @@ -408,6 +408,19 @@ class ShadInputState extends State ); }; + final effectiveMaxLengthEnforcement = widget.maxLengthEnforcement ?? + LengthLimitingTextInputFormatter.getDefaultMaxLengthEnforcement( + Theme.of(context).platform); + + final effectiveInputFormatters = [ + ...?widget.inputFormatters, + if (widget.maxLength != null) + LengthLimitingTextInputFormatter( + widget.maxLength, + maxLengthEnforcement: effectiveMaxLengthEnforcement, + ), + ]; + return ShadDisabled( disabled: !widget.enabled, child: _selectionGestureDetectorBuilder.buildGestureDetector( @@ -512,7 +525,8 @@ class ShadInputState extends State onSubmitted: widget.onSubmitted, onAppPrivateCommand: widget.onAppPrivateCommand, - inputFormatters: widget.inputFormatters, + inputFormatters: + effectiveInputFormatters, cursorWidth: widget.cursorWidth, cursorHeight: widget.cursorHeight, cursorRadius: widget.cursorRadius, @@ -536,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 new file mode 100644 index 00000000..9324642b --- /dev/null +++ b/lib/src/components/time_picker.dart @@ -0,0 +1,1027 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shadcn_ui/src/components/input.dart'; +import 'package:shadcn_ui/src/components/select.dart'; +import 'package:shadcn_ui/src/theme/components/decorator.dart'; +import 'package:shadcn_ui/src/theme/theme.dart'; +import 'package:shadcn_ui/src/utils/separated_iterable.dart'; + +@immutable +class ShadTimeOfDay { + /// 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. + const ShadTimeOfDay({ + required this.hour, + required this.minute, + required this.second, + DayPeriod? period, + }) : _period = period; + + /// 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) + : hour = time.hour, + minute = time.minute, + second = time.second, + _period = null; + + /// 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 hour, in 24 hour time from 0..23. + final int hour; + + /// The selected minute. + final int minute; + + /// The selected second. + final int second; + + /// The selected period of the day. + final DayPeriod? _period; + + /// Whether this time of day is before or after noon. + DayPeriod get period => + _period ?? + (hour < TimeOfDay.hoursPerPeriod ? DayPeriod.am : DayPeriod.pm); + + @override + bool operator ==(Object other) { + return other is ShadTimeOfDay && + other.hour == hour && + other.minute == minute && + other.second == second && + other.period == period; + } + + @override + int get hashCode => Object.hash(hour, minute, second, period); + + @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); + + var s = '$TimeOfDay($hourLabel:$minuteLabel:$secondLabel'; + + if (_period != null) { + s += ' ${_period!.name.toUpperCase()}'; + } + return s += ')'; + } + + ShadTimeOfDay copyWith({ + int? hour, + int? minute, + int? second, + DayPeriod? period, + }) { + return ShadTimeOfDay( + hour: hour ?? this.hour, + minute: minute ?? this.minute, + second: second ?? this.second, + period: period ?? this.period, + ); + } +} + +class ShadTimePickerController extends ChangeNotifier { + ShadTimePickerController({ + this.hour, + this.minute, + this.second, + this.period, + }); + ShadTimePickerController.fromTimeOfDay(ShadTimeOfDay? timeOfDay) + : hour = timeOfDay?.hour, + minute = timeOfDay?.minute, + second = timeOfDay?.second, + period = timeOfDay?.period; + + int? hour; + int? minute; + int? second; + DayPeriod? period; + + ShadTimeOfDay? get value { + if (hour == null || minute == null || second == null) return null; + return ShadTimeOfDay( + hour: hour!, + minute: minute!, + second: second!, + period: period, + ); + } + + void setHour(int? hour) { + if (this.hour == hour) return; + this.hour = hour; + notifyListeners(); + } + + void setMinute(int? minute) { + if (this.minute == minute) return; + this.minute = minute; + notifyListeners(); + } + + void setSecond(int? second) { + if (this.second == second) return; + this.second = second; + notifyListeners(); + } + + void setDayPeriod(DayPeriod? period) { + if (this.period == period) return; + this.period = period; + notifyListeners(); + } +} + +enum ShadTimePickerVariant { + primary, + period, +} + +class ShadTimePicker extends StatefulWidget { + const ShadTimePicker({ + super.key, + this.axis, + this.spacing, + this.runSpacing, + this.jumpToNextFieldWhenFilled, + this.onChanged, + this.initialValue, + this.hourLabel, + this.minuteLabel, + this.secondLabel, + this.hourPlaceholder, + this.minutePlaceholder, + this.secondPlaceholder, + this.leading, + this.trailing, + this.alignment, + this.runAlignment, + this.crossAxisAlignment, + this.maxHour = 23, + this.maxMinute = 59, + this.maxSecond = 59, + this.minHour = 0, + this.minMinute = 0, + this.minSecond = 0, + this.gap, + this.style, + this.labelStyle, + this.placeholderStyle, + this.fieldWidth, + this.fieldPadding, + this.fieldDecoration, + this.controller, + this.enabled = true, + }) : variant = ShadTimePickerVariant.primary, + initialDayPeriod = null, + periodLabel = null, + periodPlaceholder = null, + periodHeight = null, + periodDecoration = null, + periodMinWidth = null; + + const ShadTimePicker.period({ + super.key, + this.axis, + this.spacing, + this.runSpacing, + this.jumpToNextFieldWhenFilled, + this.onChanged, + this.initialValue, + this.hourLabel, + this.minuteLabel, + this.secondLabel, + this.hourPlaceholder, + this.minutePlaceholder, + this.secondPlaceholder, + this.leading, + this.trailing, + this.alignment, + this.runAlignment, + this.crossAxisAlignment, + this.maxHour = 12, + this.maxMinute = 59, + this.maxSecond = 59, + this.minHour = 0, + this.minMinute = 0, + this.minSecond = 0, + this.initialDayPeriod, + this.periodLabel, + this.periodPlaceholder, + this.periodHeight, + this.periodMinWidth, + this.gap, + this.style, + this.labelStyle, + this.placeholderStyle, + this.fieldWidth, + this.fieldPadding, + this.fieldDecoration, + this.periodDecoration, + this.controller, + this.enabled = true, + }) : variant = ShadTimePickerVariant.period; + + const ShadTimePicker.raw({ + super.key, + required this.variant, + this.axis, + this.spacing, + this.runSpacing, + this.jumpToNextFieldWhenFilled, + this.onChanged, + this.initialValue, + this.hourLabel, + this.minuteLabel, + this.secondLabel, + this.hourPlaceholder, + this.minutePlaceholder, + this.secondPlaceholder, + this.leading, + this.trailing, + this.alignment, + this.runAlignment, + this.crossAxisAlignment, + this.maxHour = 23, + this.maxMinute = 59, + this.maxSecond = 59, + this.minHour = 0, + this.minMinute = 0, + this.minSecond = 0, + this.initialDayPeriod, + this.periodLabel, + this.periodPlaceholder, + this.periodHeight, + this.periodMinWidth, + this.gap, + this.style, + this.labelStyle, + this.placeholderStyle, + this.fieldWidth, + this.fieldPadding, + this.fieldDecoration, + this.periodDecoration, + this.controller, + this.enabled = true, + }); + + /// {@template ShadTimePicker.axis} + /// The axis along which the fields are laid out. Defaults to `horizontal`. + /// {@endtemplate} + final Axis? axis; + + /// {@template ShadTimePicker.spacing} + /// The spacing between the fields in the picker. Defaults to `0`. + /// {@endtemplate} + final double? spacing; + + /// {@template ShadTimePicker.runSpacing} + /// The run spacing between the fields in the picker. Defaults to `0`. + /// {@endtemplate} + final double? runSpacing; + + /// {@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; + + /// {@template ShadTimePicker.hourPlaceholder} + /// The widget to display as the placeholder for the hour field, defaults to + /// `Text('00')`. + /// {@endtemplate} + final Widget? hourPlaceholder; + + /// {@template ShadTimePicker.minutePlaceholder} + /// The widget to display as the placeholder for the minute field, defaults to + /// `Text('00')`. + /// {@endtemplate} + final Widget? minutePlaceholder; + + /// {@template ShadTimePicker.secondPlaceholder} + /// The widget to display as the placeholder for the second field, defaults to + /// `Text('00')`. + /// {@endtemplate} + final Widget? secondPlaceholder; + + /// {@template ShadTimePicker.leading} + /// The widget to display before the fields in the picker. + /// {@endtemplate} + final Widget? leading; + + /// {@template ShadTimePicker.trailing} + /// The widget to display after the fields in the picker. + /// {@endtemplate} + final Widget? trailing; + + /// {@template ShadTimePicker.alignment} + /// The alignment of the fields in the picker. Defaults to + /// `WrapAlignment.center`. + /// {@endtemplate} + final WrapAlignment? alignment; + + /// {@template ShadTimePicker.runAlignment} + /// The alignment of the fields in the picker. Defaults to + /// `WrapAlignment.center`. + /// {@endtemplate} + final WrapAlignment? runAlignment; + + /// {@template ShadTimePicker.crossAxisAlignment} + /// The alignment of the fields in the picker. Defaults to + /// `WrapCrossAlignment.center`. + /// {@endtemplate} + final WrapCrossAlignment? crossAxisAlignment; + + /// {@template ShadTimePicker.maxHour} + /// The maximum hour value that can be selected. Defaults to 23 if [variant] + /// is [ShadTimePickerVariant.primary] or 12 if [variant] is + /// [ShadTimePickerVariant.period]. + /// {@endtemplate} + final int maxHour; + + /// {@template ShadTimePicker.maxMinute} + /// The maximum minute value that can be selected. Defaults to 59. + /// {@endtemplate} + final int maxMinute; + + /// {@template ShadTimePicker.maxSecond} + /// The maximum second value that can be selected. Defaults to 59. + /// {@endtemplate} + final int maxSecond; + + /// {@template ShadTimePicker.minHour} + /// The minimum hour value that can be selected. Defaults to 0. + /// {@endtemplate} + final int minHour; + + /// {@template ShadTimePicker.minMinute} + /// The minimum minute value that can be selected. Defaults to 0. + /// {@endtemplate} + final int minMinute; + + /// {@template ShadTimePicker.minSecond} + /// The minimum second value that can be selected. Defaults to 0. + /// {@endtemplate} + final int minSecond; + + /// {@template ShadTimePicker.variant} + /// The variant of the time picker. + /// {@endtemplate} + final ShadTimePickerVariant variant; + + /// {@template ShadTimePicker.initialDayPeriod} + /// The initial day period to show in the picker, defaults to `DayPeriod.am`. + /// {@endtemplate} + final DayPeriod? initialDayPeriod; + + /// {@template ShadTimePicker.periodLabel} + /// The widget to display as the label for the period field. + /// {@endtemplate} + final Widget? periodLabel; + + /// {@template ShadTimePicker.periodPlaceholder} + /// The widget to display as the placeholder for the period field, defaults + /// to `null`. + /// {@endtemplate} + final Widget? periodPlaceholder; + + /// {@template ShadTimePicker.periodHeight} + /// The height of the period field, defaults to `50`. + /// {@endtemplate} + final double? periodHeight; + + /// {@template ShadTimePicker.periodMinWidth} + /// The min width of the period field, defaults to `65`. + /// {@endtemplate} + final double? periodMinWidth; + + /// {@template ShadTimePicker.gap} + /// The gap between the label and the field in the picker. Defaults to `2`. + /// {@endtemplate} + final double? gap; + + /// {@template ShadTimePicker.style} + /// The style of the label. Defaults to + /// ``` + /// theme.textTheme.muted.copyWith( + /// color: theme.colorScheme.foreground, + /// fontSize: 16, + /// height: 24 / 16, + /// ), + /// ``` + /// {@endtemplate} + final TextStyle? style; + + /// {@template ShadTimePicker.placeholderStyle} + /// The style of the placeholder. Defaults to + /// ``` + /// theme.textTheme.muted.copyWith( + /// fontSize: 16, + /// height: 24 / 16, + /// ), + /// ``` + /// {@endtemplate} + final TextStyle? placeholderStyle; + + /// {@template ShadTimePicker.labelStyle} + /// The style of the label. Defaults to + /// `theme.textTheme.small.copyWith(fontSize: 12)` + /// {@endtemplate} + final TextStyle? labelStyle; + + /// {@template ShadTimePicker.fieldWidth} + /// The width of the field, defaults to `58`. + /// {@endtemplate} + final double? fieldWidth; + + /// {@template ShadTimePicker.fieldPadding} + /// The padding of the field, defaults to + /// `const EdgeInsets.symmetric(horizontal: 12, vertical: 8)`. + /// {@endtemplate} + final EdgeInsets? fieldPadding; + + /// {@template ShadTimePicker.fieldDecoration} + /// The decoration of the field, defaults to + /// ``` + /// ShadDecoration(border: ShadBorder.all( + /// color: colorScheme.border, + /// radius: radius, + /// ), + /// ), + /// ``` + /// {@endtemplate} + final ShadDecoration? fieldDecoration; + + /// {@template ShadTimePicker.periodDecoration} + /// The decoration of the field, defaults to `null`. + /// {@endtemplate} + final ShadDecoration? periodDecoration; + + /// {@template ShadTimePicker.controller} + /// The controller of the time picker, defaults to `null`. + /// {@endtemplate} + final ShadTimePickerController? controller; + + /// {@template ShadTimePicker.enabled} + /// Whether the time picker is enabled, defaults to `true`. + /// {@endtemplate} + final bool enabled; + + @override + State createState() => _ShadTimePickerState(); +} + +class _ShadTimePickerState extends State { + ShadTimePickerController? _controller; + ShadTimePickerController get controller => widget.controller ?? _controller!; + late final hourController = ShadTimePickerTextEditingController( + max: widget.maxHour, + min: widget.minHour, + text: widget.initialValue?.hour.toString().padLeft(2, '0'), + placeholderStyle: widget.placeholderStyle, + ); + late final minuteController = ShadTimePickerTextEditingController( + max: widget.maxMinute, + min: widget.minMinute, + text: widget.initialValue?.minute.toString().padLeft(2, '0'), + placeholderStyle: widget.placeholderStyle, + ); + late final secondController = ShadTimePickerTextEditingController( + max: widget.maxSecond, + min: widget.minSecond, + text: widget.initialValue?.second.toString().padLeft(2, '0'), + placeholderStyle: widget.placeholderStyle, + ); + + final focusNodes = [FocusNode(), FocusNode(), FocusNode()]; + final periodFocusNode = FocusNode(); + + @override + void initState() { + super.initState(); + if (widget.controller == null) { + _controller = ShadTimePickerController( + hour: widget.initialValue?.hour, + minute: widget.initialValue?.minute, + second: widget.initialValue?.second, + period: widget.initialDayPeriod, + ); + } + controller.addListener(onChanged); + } + + @override + void dispose() { + _controller?.dispose(); + controller.removeListener(onChanged); + for (final node in focusNodes) { + node.dispose(); + } + focusNodes.clear(); + periodFocusNode.dispose(); + super.dispose(); + } + + void onChanged() { + if (controller.hour == null || + controller.minute == null || + controller.second == null) return; + if (widget.variant == ShadTimePickerVariant.period && + controller.period == null) return; + widget.onChanged?.call( + ShadTimeOfDay( + hour: controller.hour!, + minute: controller.minute!, + second: controller.second!, + period: controller.period, + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final effectiveAxis = + widget.axis ?? theme.timePickerTheme.axis ?? Axis.horizontal; + final effectiveSpacing = + widget.spacing ?? theme.timePickerTheme.spacing ?? 0; + final effectiveRunSpacing = + widget.runSpacing ?? theme.timePickerTheme.runSpacing ?? 0; + final effectiveJumpToNextField = widget.jumpToNextFieldWhenFilled ?? + theme.timePickerTheme.jumpToNextFieldWhenFilled ?? + true; + final effectiveHourLabel = widget.hourLabel ?? const Text('Hours'); + final effectiveMinuteLabel = widget.minuteLabel ?? const Text('Minutes'); + final effectiveSecondLabel = widget.secondLabel ?? const Text('Seconds'); + final effectivePeriodLabel = widget.periodLabel ?? const Text('Period'); + + const defaultPlaceholder = Text('00'); + final effectiveHourPlaceholder = + widget.hourPlaceholder ?? defaultPlaceholder; + final effectiveMinutePlaceholder = + widget.minutePlaceholder ?? defaultPlaceholder; + final effectiveSecondPlaceholder = + widget.secondPlaceholder ?? defaultPlaceholder; + final effectivePeriodPlaceholder = + widget.periodPlaceholder ?? Text('AM', style: theme.textTheme.muted); + + final effectiveAlignment = widget.alignment ?? + theme.timePickerTheme.alignment ?? + WrapAlignment.center; + final effectiveRunAlignment = widget.runAlignment ?? + theme.timePickerTheme.runAlignment ?? + WrapAlignment.center; + final effectiveCrossAxisAlignment = widget.crossAxisAlignment ?? + theme.timePickerTheme.crossAxisAlignment ?? + WrapCrossAlignment.center; + + final effectivePeriodHeight = + widget.periodHeight ?? theme.timePickerTheme.periodHeight ?? 50; + final effectivePeriodMinWidth = + widget.periodMinWidth ?? theme.timePickerTheme.periodMinWidth ?? 65; + final effectiveGap = widget.gap ?? theme.timePickerTheme.gap ?? 2; + final effectiveFieldWidth = + widget.fieldWidth ?? theme.timePickerTheme.fieldWidth ?? 58; + final effectiveFieldPadding = widget.fieldPadding ?? + theme.timePickerTheme.fieldPadding ?? + const EdgeInsets.symmetric(horizontal: 12, vertical: 8); + + final effectiveStyle = theme.textTheme.muted + .copyWith( + color: theme.colorScheme.foreground, + fontSize: 16, + height: 24 / 16, + ) + .merge(theme.timePickerTheme.style) + .merge(widget.style); + + final effectiveLabelStyle = theme.textTheme.small + .copyWith(fontSize: 12) + .merge(theme.timePickerTheme.labelStyle) + .merge(widget.labelStyle); + + final effectiveFieldDecoration = ShadDecoration( + border: ShadBorder.all( + color: theme.colorScheme.border, + radius: theme.radius, + ), + ) + .mergeWith(theme.timePickerTheme.fieldDecoration) + .mergeWith(widget.fieldDecoration); + + final effectivePeriodDecoration = + (theme.timePickerTheme.periodDecoration ?? const ShadDecoration()) + .mergeWith(widget.periodDecoration); + + return Wrap( + direction: effectiveAxis, + spacing: effectiveSpacing, + runSpacing: effectiveRunSpacing, + alignment: effectiveAlignment, + runAlignment: effectiveRunAlignment, + crossAxisAlignment: effectiveCrossAxisAlignment, + children: [ + if (widget.leading != null) widget.leading!, + ShadTimePickerField( + focusNode: focusNodes[0], + label: effectiveHourLabel, + controller: hourController, + gap: effectiveGap, + placeholder: effectiveHourPlaceholder, + style: effectiveStyle, + labelStyle: effectiveLabelStyle, + width: effectiveFieldWidth, + padding: effectiveFieldPadding, + decoration: effectiveFieldDecoration, + enabled: widget.enabled, + onChanged: (v) { + if (v.length == 2) { + controller.setHour(int.tryParse(v)); + if (effectiveJumpToNextField) { + focusNodes[1].requestFocus(); + } + } + }, + ), + ShadTimePickerField( + focusNode: focusNodes[1], + label: effectiveMinuteLabel, + controller: minuteController, + placeholder: effectiveMinutePlaceholder, + gap: effectiveGap, + style: effectiveStyle, + labelStyle: effectiveLabelStyle, + width: effectiveFieldWidth, + padding: effectiveFieldPadding, + decoration: effectiveFieldDecoration, + enabled: widget.enabled, + onChanged: (v) { + if (v.length == 2) { + controller.setMinute(int.tryParse(v)); + if (effectiveJumpToNextField) { + focusNodes[2].requestFocus(); + } + } + }, + ), + ShadTimePickerField( + focusNode: focusNodes[2], + label: effectiveSecondLabel, + controller: secondController, + gap: effectiveGap, + placeholder: effectiveSecondPlaceholder, + style: effectiveStyle, + labelStyle: effectiveLabelStyle, + width: effectiveFieldWidth, + padding: effectiveFieldPadding, + decoration: effectiveFieldDecoration, + enabled: widget.enabled, + onChanged: (v) { + if (v.length == 2) { + controller.setSecond(int.tryParse(v)); + if (effectiveJumpToNextField && + widget.variant == ShadTimePickerVariant.period) { + periodFocusNode.requestFocus(); + } + } + }, + ), + if (widget.variant == ShadTimePickerVariant.period) + Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + DefaultTextStyle( + style: effectiveLabelStyle, + child: effectivePeriodLabel, + ), + SizedBox(height: effectiveGap), + SizedBox( + height: effectivePeriodHeight, + child: ShadSelect( + focusNode: periodFocusNode, + minWidth: effectivePeriodMinWidth, + placeholder: effectivePeriodPlaceholder, + initialValue: controller.period, + decoration: effectivePeriodDecoration, + enabled: widget.enabled, + options: DayPeriod.values + .map( + (v) => ShadOption( + value: v, + child: Text(v.name.toUpperCase()), + ), + ) + .toList(), + selectedOptionBuilder: (context, value) { + return Text(value.name.toUpperCase()); + }, + onChanged: (value) { + controller.setDayPeriod(value); + }, + ), + ), + ], + ), + if (widget.trailing != null) widget.trailing!, + ], + ); + } +} + +class ShadTimePickerField extends StatefulWidget { + const ShadTimePickerField({ + super.key, + this.label, + this.placeholder, + this.controller, + this.gap, + this.style, + this.labelStyle, + this.onChanged, + this.focusNode, + this.width, + this.padding, + this.decoration, + this.enabled = true, + }); + + final Widget? label; + final Widget? placeholder; + final ShadTimePickerTextEditingController? controller; + final double? gap; + final TextStyle? style; + final TextStyle? labelStyle; + final ValueChanged? onChanged; + final FocusNode? focusNode; + final double? width; + final EdgeInsets? padding; + final ShadDecoration? decoration; + final bool enabled; + + @override + State createState() => _ShadTimePickerFieldState(); +} + +class _ShadTimePickerFieldState extends State { + ShadTimePickerTextEditingController? _controller; + ShadTimePickerTextEditingController get controller => + widget.controller ?? _controller!; + + @override + void initState() { + super.initState(); + if (widget.controller == null) { + _controller = ShadTimePickerTextEditingController(); + } + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + 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(theme.timePickerTheme.placeholderStyle) + .merge(controller.placeholderStyle); + + final defaultLabelStyle = theme.textTheme.small.copyWith(fontSize: 12); + final effectiveLabelStyle = defaultLabelStyle.merge(widget.labelStyle); + final effectiveWidth = widget.width ?? 58; + final effectivePadding = widget.padding ?? + const EdgeInsets.symmetric(horizontal: 12, vertical: 8); + + final effectiveDecoration = ShadDecoration( + border: ShadBorder.all( + color: theme.colorScheme.border, + radius: theme.radius, + ), + ).mergeWith(widget.decoration); + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.label != null) + DefaultTextStyle( + style: effectiveLabelStyle, + child: widget.label!, + ), + SizedBox( + width: effectiveWidth, + child: ShadInput( + enabled: widget.enabled, + focusNode: widget.focusNode, + style: effectiveStyle, + placeholderStyle: effectivePlaceholderStyle, + controller: controller, + decoration: effectiveDecoration, + placeholder: widget.placeholder, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + maxLength: 2, + showCursor: false, + maxLengthEnforcement: MaxLengthEnforcement.none, + padding: effectivePadding, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + onChanged: (value) { + widget.onChanged?.call(value); + }, + ), + ), + ].separatedBy(SizedBox(height: effectiveGap)), + ); + } +} + +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, + ); + + final theme = ShadTheme.of(context); + + final defaultPlaceholderStyle = theme.textTheme.muted.copyWith( + fontSize: 16, + height: 24 / 16, + ); + final effectivePlaceholderStyle = defaultPlaceholderStyle + .merge(theme.timePickerTheme.placeholderStyle) + .merge(placeholderStyle); + + final intValue = int.tryParse(value.text); + if (intValue == null) return const TextSpan(); + + return TextSpan( + style: style, + children: [ + if (value.text.length == 1 && intValue < 10) + TextSpan(text: '0', style: effectivePlaceholderStyle), + TextSpan(text: 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, + ); + } +} diff --git a/lib/src/theme/components/time_picker.dart b/lib/src/theme/components/time_picker.dart new file mode 100644 index 00000000..ab2db4a2 --- /dev/null +++ b/lib/src/theme/components/time_picker.dart @@ -0,0 +1,237 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/src/theme/components/decorator.dart'; + +@immutable +class ShadTimePickerTheme { + const ShadTimePickerTheme({ + this.merge = true, + this.axis, + this.spacing, + this.runSpacing, + this.jumpToNextFieldWhenFilled, + this.alignment, + this.runAlignment, + this.crossAxisAlignment, + this.initialDayPeriod, + this.periodHeight, + this.periodMinWidth, + this.gap, + this.style, + this.placeholderStyle, + this.labelStyle, + this.fieldWidth, + this.fieldPadding, + this.fieldDecoration, + this.periodDecoration, + }); + + final bool merge; + + /// {@macro ShadTimePicker.axis} + final Axis? axis; + + /// {@macro ShadTimePicker.spacing} + final double? spacing; + + /// {@macro ShadTimePicker.runSpacing} + final double? runSpacing; + + /// {@macro ShadTimePicker.jumpToNextFieldWhenFilled} + final bool? jumpToNextFieldWhenFilled; + + /// {@macro ShadTimePicker.alignment} + final WrapAlignment? alignment; + + /// {@macro ShadTimePicker.runAlignment} + final WrapAlignment? runAlignment; + + /// {@macro ShadTimePicker.crossAxisAlignment} + final WrapCrossAlignment? crossAxisAlignment; + + /// {@macro ShadTimePicker.initialDayPeriod} + final DayPeriod? initialDayPeriod; + + /// {@macro ShadTimePicker.periodHeight} + final double? periodHeight; + + /// {@macro ShadTimePicker.periodMinWidth} + final double? periodMinWidth; + + /// {@macro ShadTimePicker.gap} + final double? gap; + + /// {@macro ShadTimePicker.style} + final TextStyle? style; + + /// {@macro ShadTimePicker.placeholderStyle} + final TextStyle? placeholderStyle; + + /// {@macro ShadTimePicker.labelStyle} + final TextStyle? labelStyle; + + /// {@macro ShadTimePicker.fieldWidth} + final double? fieldWidth; + + /// {@macro ShadTimePicker.fieldPadding} + final EdgeInsets? fieldPadding; + + /// {@macro ShadTimePicker.fieldDecoration} + final ShadDecoration? fieldDecoration; + + /// {@macro ShadTimePicker.periodDecoration} + final ShadDecoration? periodDecoration; + + static ShadTimePickerTheme lerp( + ShadTimePickerTheme a, + ShadTimePickerTheme b, + double t, + ) { + if (identical(a, b)) return a; + return ShadTimePickerTheme( + merge: b.merge, + axis: t < 0.5 ? a.axis : b.axis, + spacing: lerpDouble(a.spacing, b.spacing, t), + runSpacing: lerpDouble(a.runSpacing, b.runSpacing, t), + jumpToNextFieldWhenFilled: + t < 0.5 ? a.jumpToNextFieldWhenFilled : b.jumpToNextFieldWhenFilled, + alignment: t < .5 ? a.alignment : b.alignment, + runAlignment: t < .5 ? a.runAlignment : b.runAlignment, + crossAxisAlignment: t < .5 ? a.crossAxisAlignment : b.crossAxisAlignment, + initialDayPeriod: t < .5 ? a.initialDayPeriod : b.initialDayPeriod, + periodHeight: lerpDouble(a.periodHeight, b.periodHeight, t), + periodMinWidth: lerpDouble(a.periodMinWidth, b.periodMinWidth, t), + gap: lerpDouble(a.gap, b.gap, t), + style: TextStyle.lerp(a.style, b.style, t), + placeholderStyle: + TextStyle.lerp(a.placeholderStyle, b.placeholderStyle, t), + labelStyle: TextStyle.lerp(a.labelStyle, b.labelStyle, t), + fieldWidth: t < .5 ? a.fieldWidth : b.fieldWidth, + fieldPadding: EdgeInsets.lerp(a.fieldPadding, b.fieldPadding, t), + fieldDecoration: + ShadDecoration.lerp(a.fieldDecoration, b.fieldDecoration, t), + periodDecoration: + ShadDecoration.lerp(a.periodDecoration, b.periodDecoration, t), + ); + } + + ShadTimePickerTheme copyWith({ + bool? merge, + Axis? axis, + double? spacing, + double? runSpacing, + bool? jumpToNextFieldWhenFilled, + WrapAlignment? alignment, + WrapAlignment? runAlignment, + WrapCrossAlignment? crossAxisAlignment, + DayPeriod? initialDayPeriod, + double? periodHeight, + double? periodMinWidth, + double? gap, + TextStyle? style, + TextStyle? placeholderStyle, + TextStyle? labelStyle, + double? fieldWidth, + EdgeInsets? fieldPadding, + ShadDecoration? fieldDecoration, + ShadDecoration? periodDecoration, + }) { + return ShadTimePickerTheme( + merge: merge ?? this.merge, + axis: axis ?? this.axis, + spacing: spacing ?? this.spacing, + runSpacing: runSpacing ?? this.runSpacing, + jumpToNextFieldWhenFilled: + jumpToNextFieldWhenFilled ?? this.jumpToNextFieldWhenFilled, + alignment: alignment ?? this.alignment, + runAlignment: runAlignment ?? this.runAlignment, + crossAxisAlignment: crossAxisAlignment ?? this.crossAxisAlignment, + initialDayPeriod: initialDayPeriod ?? this.initialDayPeriod, + periodHeight: periodHeight ?? this.periodHeight, + periodMinWidth: periodMinWidth ?? this.periodMinWidth, + gap: gap ?? this.gap, + style: style ?? this.style, + placeholderStyle: placeholderStyle ?? this.placeholderStyle, + labelStyle: labelStyle ?? this.labelStyle, + fieldWidth: fieldWidth ?? this.fieldWidth, + fieldPadding: fieldPadding ?? this.fieldPadding, + fieldDecoration: fieldDecoration ?? this.fieldDecoration, + periodDecoration: periodDecoration ?? this.periodDecoration, + ); + } + + ShadTimePickerTheme mergeWith(ShadTimePickerTheme? other) { + if (other == null) return this; + if (!other.merge) return other; + return copyWith( + axis: other.axis, + spacing: other.spacing, + runSpacing: other.runSpacing, + jumpToNextFieldWhenFilled: other.jumpToNextFieldWhenFilled, + alignment: other.alignment, + runAlignment: other.runAlignment, + crossAxisAlignment: other.crossAxisAlignment, + initialDayPeriod: other.initialDayPeriod, + periodHeight: other.periodHeight, + periodMinWidth: other.periodMinWidth, + gap: other.gap, + style: other.style, + placeholderStyle: other.placeholderStyle, + labelStyle: other.labelStyle, + fieldWidth: other.fieldWidth, + fieldPadding: other.fieldPadding, + fieldDecoration: other.fieldDecoration, + periodDecoration: other.periodDecoration, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is ShadTimePickerTheme && + other.merge == merge && + other.axis == axis && + other.spacing == spacing && + other.runSpacing == runSpacing && + other.jumpToNextFieldWhenFilled == jumpToNextFieldWhenFilled && + other.alignment == alignment && + other.runAlignment == runAlignment && + other.crossAxisAlignment == crossAxisAlignment && + other.initialDayPeriod == initialDayPeriod && + other.periodHeight == periodHeight && + other.periodMinWidth == periodMinWidth && + other.gap == gap && + other.style == style && + other.placeholderStyle == placeholderStyle && + other.labelStyle == labelStyle && + other.fieldWidth == fieldWidth && + other.fieldPadding == fieldPadding && + other.fieldDecoration == fieldDecoration && + other.periodDecoration == periodDecoration; + } + + @override + int get hashCode { + return merge.hashCode ^ + axis.hashCode ^ + spacing.hashCode ^ + runSpacing.hashCode ^ + jumpToNextFieldWhenFilled.hashCode ^ + alignment.hashCode ^ + runAlignment.hashCode ^ + crossAxisAlignment.hashCode ^ + initialDayPeriod.hashCode ^ + periodHeight.hashCode ^ + periodMinWidth.hashCode ^ + gap.hashCode ^ + style.hashCode ^ + placeholderStyle.hashCode ^ + labelStyle.hashCode ^ + fieldWidth.hashCode ^ + fieldPadding.hashCode ^ + fieldDecoration.hashCode ^ + periodDecoration.hashCode; + } +} diff --git a/lib/src/theme/data.dart b/lib/src/theme/data.dart index 4b6ae611..c0d4d95d 100644 --- a/lib/src/theme/data.dart +++ b/lib/src/theme/data.dart @@ -26,6 +26,7 @@ import 'package:shadcn_ui/src/theme/components/slider.dart'; import 'package:shadcn_ui/src/theme/components/switch.dart'; import 'package:shadcn_ui/src/theme/components/table.dart'; import 'package:shadcn_ui/src/theme/components/tabs.dart'; +import 'package:shadcn_ui/src/theme/components/time_picker.dart'; import 'package:shadcn_ui/src/theme/components/toast.dart'; import 'package:shadcn_ui/src/theme/components/tooltip.dart'; import 'package:shadcn_ui/src/theme/text_theme/theme.dart'; @@ -86,6 +87,7 @@ class ShadThemeData extends ShadBaseTheme { ShadContextMenuTheme? contextMenuTheme, ShadCalendarTheme? calendarTheme, ShadDatePickerTheme? datePickerTheme, + ShadTimePickerTheme? timePickerTheme, }) { final effectiveRadius = radius ?? const BorderRadius.all(Radius.circular(6)); @@ -225,6 +227,8 @@ class ShadThemeData extends ShadBaseTheme { calendarTheme: effectiveVariant.calendarTheme().mergeWith(calendarTheme), datePickerTheme: effectiveVariant.datePickerTheme().mergeWith(datePickerTheme), + timePickerTheme: + effectiveVariant.timePickerTheme().mergeWith(timePickerTheme), ); } @@ -276,6 +280,7 @@ class ShadThemeData extends ShadBaseTheme { required super.contextMenuTheme, required super.calendarTheme, required super.datePickerTheme, + required super.timePickerTheme, }); static ShadThemeData lerp(ShadThemeData a, ShadThemeData b, double t) { @@ -374,6 +379,8 @@ class ShadThemeData extends ShadBaseTheme { ShadCalendarTheme.lerp(a.calendarTheme, b.calendarTheme, t), datePickerTheme: ShadDatePickerTheme.lerp(a.datePickerTheme, b.datePickerTheme, t), + timePickerTheme: + ShadTimePickerTheme.lerp(a.timePickerTheme, b.timePickerTheme, t), ); } @@ -428,7 +435,8 @@ class ShadThemeData extends ShadBaseTheme { other.tabsTheme == tabsTheme && other.contextMenuTheme == contextMenuTheme && other.calendarTheme == calendarTheme && - other.datePickerTheme == datePickerTheme; + other.datePickerTheme == datePickerTheme && + other.timePickerTheme == timePickerTheme; } @override @@ -479,7 +487,8 @@ class ShadThemeData extends ShadBaseTheme { tabsTheme.hashCode ^ contextMenuTheme.hashCode ^ calendarTheme.hashCode ^ - datePickerTheme.hashCode; + datePickerTheme.hashCode ^ + timePickerTheme.hashCode; } ShadThemeData copyWith({ @@ -530,6 +539,7 @@ class ShadThemeData extends ShadBaseTheme { ShadContextMenuTheme? contextMenuTheme, ShadCalendarTheme? calendarTheme, ShadDatePickerTheme? datePickerTheme, + ShadTimePickerTheme? timePickerTheme, }) { return ShadThemeData( colorScheme: colorScheme ?? this.colorScheme, @@ -584,6 +594,7 @@ class ShadThemeData extends ShadBaseTheme { contextMenuTheme: contextMenuTheme ?? this.contextMenuTheme, calendarTheme: calendarTheme ?? this.calendarTheme, datePickerTheme: datePickerTheme ?? this.datePickerTheme, + timePickerTheme: timePickerTheme ?? this.timePickerTheme, ); } } diff --git a/lib/src/theme/themes/base.dart b/lib/src/theme/themes/base.dart index a213619d..cbc77de7 100644 --- a/lib/src/theme/themes/base.dart +++ b/lib/src/theme/themes/base.dart @@ -24,6 +24,7 @@ import 'package:shadcn_ui/src/theme/components/slider.dart'; import 'package:shadcn_ui/src/theme/components/switch.dart'; import 'package:shadcn_ui/src/theme/components/table.dart'; import 'package:shadcn_ui/src/theme/components/tabs.dart'; +import 'package:shadcn_ui/src/theme/components/time_picker.dart'; import 'package:shadcn_ui/src/theme/components/toast.dart'; import 'package:shadcn_ui/src/theme/components/tooltip.dart'; import 'package:shadcn_ui/src/theme/text_theme/theme.dart'; @@ -80,6 +81,7 @@ abstract class ShadBaseTheme { required this.contextMenuTheme, required this.calendarTheme, required this.datePickerTheme, + required this.timePickerTheme, }); final ShadColorScheme colorScheme; @@ -129,6 +131,7 @@ abstract class ShadBaseTheme { final ShadContextMenuTheme contextMenuTheme; final ShadCalendarTheme calendarTheme; final ShadDatePickerTheme datePickerTheme; + final ShadTimePickerTheme timePickerTheme; } @immutable @@ -173,4 +176,5 @@ abstract class ShadThemeVariant { ShadContextMenuTheme contextMenuTheme(); ShadCalendarTheme calendarTheme(); ShadDatePickerTheme datePickerTheme(); + ShadTimePickerTheme timePickerTheme(); } diff --git a/lib/src/theme/themes/default_theme_no_secondary_border_variant.dart b/lib/src/theme/themes/default_theme_no_secondary_border_variant.dart index 7b84f939..1efcd2b2 100644 --- a/lib/src/theme/themes/default_theme_no_secondary_border_variant.dart +++ b/lib/src/theme/themes/default_theme_no_secondary_border_variant.dart @@ -32,6 +32,7 @@ import 'package:shadcn_ui/src/theme/components/slider.dart'; import 'package:shadcn_ui/src/theme/components/switch.dart'; import 'package:shadcn_ui/src/theme/components/table.dart'; import 'package:shadcn_ui/src/theme/components/tabs.dart'; +import 'package:shadcn_ui/src/theme/components/time_picker.dart'; import 'package:shadcn_ui/src/theme/components/toast.dart'; import 'package:shadcn_ui/src/theme/components/tooltip.dart'; import 'package:shadcn_ui/src/theme/text_theme/text_styles_default.dart'; @@ -823,7 +824,7 @@ class ShadDefaultThemeNoSecondaryBorderVariant extends ShadThemeVariant { color: colorScheme.primaryForeground, ), insideRangeDayButtonTextStyle: textTheme().small.copyWith( - color: colorScheme.primaryForeground, + color: colorScheme.foreground, ), dayButtonTextStyle: textTheme().small.copyWith( fontWeight: FontWeight.normal, @@ -867,4 +868,52 @@ class ShadDefaultThemeNoSecondaryBorderVariant extends ShadThemeVariant { iconSrc: LucideIcons.calendar, ); } + + @override + ShadTimePickerTheme timePickerTheme() { + return ShadTimePickerTheme( + axis: Axis.horizontal, + spacing: 4, + runSpacing: 4, + jumpToNextFieldWhenFilled: true, + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + gap: 2, + style: effectiveTextTheme.muted.copyWith( + color: colorScheme.foreground, + fontSize: 16, + height: 24 / 16, + ), + placeholderStyle: effectiveTextTheme.muted.copyWith( + fontSize: 16, + height: 24 / 16, + ), + labelStyle: effectiveTextTheme.small.copyWith(fontSize: 12), + fieldWidth: 50, + fieldPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + periodHeight: 44, + periodMinWidth: 65, + fieldDecoration: ShadDecoration( + border: ShadBorder.all( + width: 2, + color: colorScheme.border, + radius: radius, + ), + ), + periodDecoration: ShadDecoration( + border: ShadBorder.all( + width: 2, + color: colorScheme.border, + radius: radius, + ), + focusedBorder: ShadBorder.all( + width: 2, + color: colorScheme.ring, + radius: radius, + padding: const EdgeInsets.symmetric(horizontal: 1), + ), + ), + ); + } } diff --git a/lib/src/theme/themes/default_theme_variant.dart b/lib/src/theme/themes/default_theme_variant.dart index 02466911..034890ed 100644 --- a/lib/src/theme/themes/default_theme_variant.dart +++ b/lib/src/theme/themes/default_theme_variant.dart @@ -32,6 +32,7 @@ import 'package:shadcn_ui/src/theme/components/slider.dart'; import 'package:shadcn_ui/src/theme/components/switch.dart'; import 'package:shadcn_ui/src/theme/components/table.dart'; import 'package:shadcn_ui/src/theme/components/tabs.dart'; +import 'package:shadcn_ui/src/theme/components/time_picker.dart'; import 'package:shadcn_ui/src/theme/components/toast.dart'; import 'package:shadcn_ui/src/theme/components/tooltip.dart'; import 'package:shadcn_ui/src/theme/text_theme/text_styles_default.dart'; @@ -840,4 +841,38 @@ class ShadDefaultThemeVariant extends ShadThemeVariant { iconSrc: LucideIcons.calendar, ); } + + @override + ShadTimePickerTheme timePickerTheme() { + return ShadTimePickerTheme( + axis: Axis.horizontal, + spacing: 0, + runSpacing: 0, + jumpToNextFieldWhenFilled: true, + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + gap: 2, + style: effectiveTextTheme.muted.copyWith( + color: colorScheme.foreground, + fontSize: 16, + height: 24 / 16, + ), + placeholderStyle: effectiveTextTheme.muted.copyWith( + fontSize: 16, + height: 24 / 16, + ), + labelStyle: effectiveTextTheme.small.copyWith(fontSize: 12), + fieldWidth: 58, + fieldPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + periodHeight: 50, + periodMinWidth: 65, + fieldDecoration: ShadDecoration( + border: ShadBorder.all( + color: colorScheme.border, + radius: radius, + ), + ), + ); + } } diff --git a/playground/analysis_options.yaml b/playground/analysis_options.yaml index 0d290213..9731ab42 100644 --- a/playground/analysis_options.yaml +++ b/playground/analysis_options.yaml @@ -7,6 +7,9 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + errors: + avoid_web_libraries_in_flutter: ignore include: package:flutter_lints/flutter.yaml linter: @@ -23,6 +26,5 @@ linter: rules: # avoid_print: false # Uncomment to disable the `avoid_print` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options diff --git a/playground/lib/main.dart b/playground/lib/main.dart index 5d1f4bd6..2b83730b 100644 --- a/playground/lib/main.dart +++ b/playground/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:js_interop'; + import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:playground/pages/accordion.dart'; @@ -24,11 +26,13 @@ import 'package:playground/pages/slider.dart'; import 'package:playground/pages/switch.dart'; import 'package:playground/pages/table.dart'; import 'package:playground/pages/tabs.dart'; +import 'package:playground/pages/time_picker.dart'; import 'package:playground/pages/toast.dart'; import 'package:playground/pages/tooltip.dart'; import 'package:playground/pages/typography.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:flutter_web_plugins/url_strategy.dart'; +import 'package:web/web.dart'; extension on GoRouterState { bool? getBoolFromArg(String name) { @@ -47,9 +51,31 @@ void main() { runApp(const MyApp()); } -class MyApp extends StatelessWidget { +class MyApp extends StatefulWidget { const MyApp({super.key}); + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + // Workaround for https://github.com/flutter/flutter/issues/155265 + void onBlur(Event e) { + (document.activeElement as HTMLElement?)?.blur(); + } + + @override + void initState() { + super.initState(); + window.addEventListener('blur', onBlur.toJS); + } + + @override + void dispose() { + window.removeEventListener('blur', onBlur.toJS); + super.dispose(); + } + @override Widget build(BuildContext context) { final theme = Uri.base.queryParameters['theme'] ?? 'light'; @@ -291,5 +317,15 @@ final _router = GoRouter( ); }, ), + GoRoute( + path: '/time-picker', + builder: (context, state) { + final style = state.uri.queryParameters['style'] ?? + ShadTimePickerVariant.primary.name; + return TimePickerPage( + style: ShadTimePickerVariant.values.byName(style), + ); + }, + ), ], ); diff --git a/playground/lib/pages/form.dart b/playground/lib/pages/form.dart index 42f2e983..2bd12839 100644 --- a/playground/lib/pages/form.dart +++ b/playground/lib/pages/form.dart @@ -11,6 +11,8 @@ enum FormStyle { radioField, datePickerField, dateRangePickerField, + timePickerField, + periodTimePickerField, } enum NotifyAbout { @@ -168,6 +170,21 @@ class _FormPageState extends State { return null; }, ), + FormStyle.timePickerField => ShadTimePickerFormField( + label: const Text('Pick a time'), + onChanged: print, + description: + const Text('The time of the day you want to pick'), + validator: (v) => v == null ? 'A time is required' : null, + ), + FormStyle.periodTimePickerField => + ShadTimePickerFormField.period( + label: const Text('Pick a time'), + onChanged: print, + description: + const Text('The time of the day you want to pick'), + validator: (v) => v == null ? 'A time is required' : null, + ), }, const SizedBox(height: 16), ShadButton( diff --git a/playground/lib/pages/time_picker.dart b/playground/lib/pages/time_picker.dart new file mode 100644 index 00000000..b3176478 --- /dev/null +++ b/playground/lib/pages/time_picker.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +import 'package:shadcn_ui/shadcn_ui.dart'; + +class TimePickerPage extends StatelessWidget { + const TimePickerPage({ + super.key, + required this.style, + }); + + final ShadTimePickerVariant style; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 24), + child: Center( + child: switch (style) { + ShadTimePickerVariant.primary => const PrimaryTimePicker(), + ShadTimePickerVariant.period => const PeriodTimePicker(), + }, + ), + ), + ); + } +} + +class PrimaryTimePicker extends StatelessWidget { + const PrimaryTimePicker({super.key}); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: const ShadTimePicker( + trailing: Padding( + padding: EdgeInsets.only(left: 8, top: 14), + child: ShadImage.square(LucideIcons.clock4, size: 16), + ), + ), + ); + } +} + +class PeriodTimePicker extends StatelessWidget { + const PeriodTimePicker({super.key}); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: const ShadTimePicker.period(), + ); + } +} diff --git a/playground/pubspec.yaml b/playground/pubspec.yaml index 886b99b1..09f167b0 100644 --- a/playground/pubspec.yaml +++ b/playground/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: shadcn_ui: path: .. awesome_flutter_extensions: ^1.2.0 + web: ^1.1.0 dev_dependencies: flutter_test: diff --git a/pubspec.yaml b/pubspec.yaml index cdf23f95..3ca65490 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: shadcn_ui description: shadcn-ui ported in Flutter. Awesome UI components for Flutter, fully customizable. -version: 0.15.4 +version: 0.16.0 homepage: https://flutter-shadcn-ui.mariuti.com repository: https://github.com/nank1ro/flutter-shadcn-ui documentation: https://flutter-shadcn-ui.mariuti.com