diff --git a/LICENSE-3RD-PARTY b/LICENSE-3RD-PARTY index 29951d3..9b40f47 100644 --- a/LICENSE-3RD-PARTY +++ b/LICENSE-3RD-PARTY @@ -1,6 +1,92 @@ ------------------------------------------------------------------------------ - BSD-3-Clause - applies to: - - DatePickerDialog - - Quill ------------------------------------------------------------------------------ \ No newline at end of file +# THIRD PARTY LICENSES + +--- + +### Flutter + +Applies to: + +- interval_time_picker.dart +- date_range_picker.dart + +Copyright 2014 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + +### Quill + +Applies to: + +- quill.min.js + +Copyright (c) 2017-2024, Slab +Copyright (c) 2014, Jason Chen +Copyright (c) 2013, salesforce.com +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + +### interval_time_picker + +Applies to: + +- interval_time_picker.dart + +Copyright 2020-2022 Fleximex + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 7565d3e..f47e146 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -216,7 +216,7 @@ SPEC CHECKSUMS: flutter_image_compress_common: ec1d45c362c9d30a3f6a0426c297f47c52007e3e flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0 flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 - image_picker_ios: b545a5f16c0fa88e3ecbbce3ed4de45567a8ec18 + image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9 JPSVolumeButtonHandler: 53110330c9168ed325def93eabff39f0fe3e8082 just_audio: baa7252489dbcf47a4c7cc9ca663e9661c99aafa @@ -235,9 +235,9 @@ SPEC CHECKSUMS: sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7 SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f - url_launcher_ios: 6116280ddcfe98ab8820085d8d76ae7449447586 + url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe video_compress: fce97e4fb1dfd88175aa07d2ffc8a2f297f87fbe - video_player_avfoundation: 2b4384f3b157206b5e150a0083cdc0c905d260d3 + video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3 wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1 PODFILE CHECKSUM: ef2759a31e8883d6b534c00ef22a7f4104f8de0d diff --git a/example/lib/pages/theme/colors.dart b/example/lib/pages/theme/colors.dart index 226a5a6..1af3ed3 100644 --- a/example/lib/pages/theme/colors.dart +++ b/example/lib/pages/theme/colors.dart @@ -95,12 +95,12 @@ class ColorsDemo extends StatelessWidget { 'subtitle': 'Theme.of(context).colorScheme.onSurface', }, { - 'color': Theme.of(context).colorScheme.background, + 'color': Theme.of(context).colorScheme.surface, 'name': 'background', 'subtitle': 'Theme.of(context).colorScheme.background', }, { - 'color': Theme.of(context).colorScheme.onBackground, + 'color': Theme.of(context).colorScheme.onSurface, 'name': 'onBackground', 'subtitle': 'Theme.of(context).colorScheme.onBackground', }, diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock index e458fbb..833a392 100644 --- a/example/macos/Podfile.lock +++ b/example/macos/Podfile.lock @@ -126,9 +126,9 @@ SPEC CHECKSUMS: shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3 - url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 + url_launcher_macos: 5f437abeda8c85500ceb03f5c1938a8c5a705399 video_compress: c896234f100791b5fef7f049afa38f6d2ef7b42f - video_player_avfoundation: 2b4384f3b157206b5e150a0083cdc0c905d260d3 + video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3 wakelock_plus: 4783562c9a43d209c458cb9b30692134af456269 PODFILE CHECKSUM: 813f07c89880cd0ec4c0da55108415b820f8783a diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 1c72c70..f64d2c5 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -22,6 +22,8 @@ dependencies: scrollable_positioned_list: ^0.3.8 url_launcher: ^6.2.5 cross_file: ^0.3.3+8 + zeta_flutter: any + intl: any flutter: uses-material-design: true diff --git a/lib/src/components/atoms/button.dart b/lib/src/components/atoms/button.dart index c922017..0ff486a 100644 --- a/lib/src/components/atoms/button.dart +++ b/lib/src/components/atoms/button.dart @@ -251,7 +251,7 @@ class ZdsButton extends StatelessWidget { final Color defaultBackground = customColor ?? (isDangerButton ? zetaColors.negative : zetaColors.secondary); // Common textStyle for all variants. - final textStyle = MaterialStateProperty.all(textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500)); + final textStyle = WidgetStateProperty.all(textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500)); // Helper function to calculate the overlay color. Color calculateOverlay({double opacity = 0.1, Color? background}) { @@ -261,44 +261,44 @@ class ZdsButton extends StatelessWidget { switch (variant) { case ZdsButtonVariant.filled: return ButtonStyle( - padding: MaterialStateProperty.all(tp), + padding: WidgetStateProperty.all(tp), textStyle: textStyle, - foregroundColor: materialStatePropertyResolver( + foregroundColor: widgetStatePropertyResolver( defaultValue: defaultBackground.onColor, disabledValue: zetaColors.textDisabled, ), - backgroundColor: materialStatePropertyResolver( + backgroundColor: widgetStatePropertyResolver( defaultValue: defaultBackground, disabledValue: zetaColors.surfaceDisabled, ), - overlayColor: materialStatePropertyResolver( + overlayColor: widgetStatePropertyResolver( hoveredValue: defaultBackground.darken(5), // Slight darkening for hover pressedValue: defaultBackground.darken(15), // More noticeable darkening for pressed state defaultValue: Colors.transparent, ), - side: materialStatePropertyResolver( + side: widgetStatePropertyResolver( focusedValue: BorderSide(color: zetaColors.secondary.subtle, width: 3), disabledValue: BorderSide(color: zetaColors.borderDisabled), ), ); case ZdsButtonVariant.outlined: return ButtonStyle( - padding: MaterialStateProperty.all(tp), + padding: WidgetStateProperty.all(tp), textStyle: textStyle, - foregroundColor: materialStatePropertyResolver( + foregroundColor: widgetStatePropertyResolver( defaultValue: defaultBackground, disabledValue: zetaColors.textDisabled, ), - backgroundColor: materialStatePropertyResolver( + backgroundColor: widgetStatePropertyResolver( defaultValue: Colors.transparent, disabledValue: zetaColors.surfaceDisabled, ), - overlayColor: materialStatePropertyResolver( + overlayColor: widgetStatePropertyResolver( defaultValue: Colors.transparent, hoveredValue: calculateOverlay(), pressedValue: calculateOverlay(opacity: 0.2), ), - side: materialStatePropertyResolver( + side: widgetStatePropertyResolver( focusedValue: BorderSide(color: zetaColors.secondary.subtle, width: 3), defaultValue: BorderSide(color: defaultBackground), disabledValue: BorderSide(color: zetaColors.borderDisabled), @@ -306,43 +306,43 @@ class ZdsButton extends StatelessWidget { ); case ZdsButtonVariant.text: return ButtonStyle( - padding: MaterialStateProperty.all(tp), + padding: WidgetStateProperty.all(tp), textStyle: textStyle, - foregroundColor: materialStatePropertyResolver( + foregroundColor: widgetStatePropertyResolver( defaultValue: isOnDarkBackground ? zetaColors.textInverse : defaultBackground, disabledValue: zetaColors.textDisabled, ), - backgroundColor: materialStatePropertyResolver( + backgroundColor: widgetStatePropertyResolver( defaultValue: Colors.transparent, ), - overlayColor: materialStatePropertyResolver( + overlayColor: widgetStatePropertyResolver( defaultValue: Colors.transparent, hoveredValue: calculateOverlay(), pressedValue: calculateOverlay(opacity: 0.2), ), - side: materialStatePropertyResolver( + side: widgetStatePropertyResolver( focusedValue: BorderSide(color: zetaColors.secondary.subtle, width: 3), disabledValue: const BorderSide(color: Colors.transparent), ), ); case ZdsButtonVariant.muted: return ButtonStyle( - padding: MaterialStateProperty.all(tp), + padding: WidgetStateProperty.all(tp), textStyle: textStyle, - foregroundColor: materialStatePropertyResolver( + foregroundColor: widgetStatePropertyResolver( defaultValue: zetaColors.textDefault, disabledValue: zetaColors.textDisabled, ), - backgroundColor: materialStatePropertyResolver( + backgroundColor: widgetStatePropertyResolver( defaultValue: Colors.transparent, disabledValue: zetaColors.surfaceDisabled, ), - overlayColor: materialStatePropertyResolver( + overlayColor: widgetStatePropertyResolver( defaultValue: Colors.transparent, hoveredValue: calculateOverlay(background: zetaColors.borderDefault), pressedValue: calculateOverlay(background: zetaColors.borderDefault, opacity: 0.2), ), - side: materialStatePropertyResolver( + side: widgetStatePropertyResolver( focusedValue: BorderSide(color: zetaColors.secondary.subtle, width: 3), disabledValue: BorderSide(color: zetaColors.borderDisabled), defaultValue: BorderSide(color: zetaColors.borderDefault), diff --git a/lib/src/components/atoms/expandable.dart b/lib/src/components/atoms/expandable.dart index a02146f..176e181 100644 --- a/lib/src/components/atoms/expandable.dart +++ b/lib/src/components/atoms/expandable.dart @@ -45,7 +45,7 @@ class ZdsExpandable extends StatelessWidget { /// The color to be used for the fadeout gradient indicating the widget is collapsed. /// - /// Defaults to [ColorScheme.background]. + /// Defaults to [ColorScheme.surface]. final Color? color; @override @@ -53,7 +53,7 @@ class ZdsExpandable extends StatelessWidget { return child.readMore( collapsedButtonText: collapsedButtonText, expandedButtonText: expandedButtonText, - color: color ?? Theme.of(context).colorScheme.background, + color: color ?? Theme.of(context).colorScheme.surface, minHeight: minHeight, ); } @@ -137,7 +137,7 @@ class _ExpandableContainerState extends State<_ExpandableContainer> with SingleT contentKey: _keyText, button: TextButton( style: TextButton.styleFrom( - foregroundColor: Theme.of(context).elevatedButtonTheme.style!.backgroundColor!.resolve({}), + foregroundColor: Theme.of(context).elevatedButtonTheme.style!.backgroundColor!.resolve({}), backgroundColor: Colors.transparent, ), onPressed: isExpanded ? collapse : expand, @@ -290,7 +290,7 @@ extension ExpandableTextExtension on Widget { /// /// [collapsedButtonText] and [expandedButtonText] define the button's text for when the widget is collapsed and /// expanded respectively. [color] defines the color to be used for the fadeout gradient indicating the widget is - /// collapsed, and defaults to [ColorScheme.background]. + /// collapsed, and defaults to [ColorScheme.surface]. /// /// See also: /// @@ -313,7 +313,7 @@ extension ExpandableTextExtension on Widget { collapsedButtonText.isEmpty ? strings.get('READ_MORE', 'Read more') : collapsedButtonText, expandedButtonText: expandedButtonText.isEmpty ? strings.get('COLLAPSE', 'Collapse') : expandedButtonText, minHeight: minHeight, - color: color ?? Theme.of(context).colorScheme.background, + color: color ?? Theme.of(context).colorScheme.surface, child: this, ); }, diff --git a/lib/src/components/atoms/toggle_button.dart b/lib/src/components/atoms/toggle_button.dart index 62722d3..62856be 100644 --- a/lib/src/components/atoms/toggle_button.dart +++ b/lib/src/components/atoms/toggle_button.dart @@ -135,7 +135,7 @@ class ZdsToggleButtonState extends State { color: (index == _selectedValue) ? widget.foregroundColor ?? (widget.backgroundColor ?? Theme.of(context).colorScheme.primary).onColor - : Theme.of(context).colorScheme.onBackground, + : Theme.of(context).colorScheme.onSurface, ), child: Text(widget.values[index]), ), diff --git a/lib/src/components/molecules/block_table.dart b/lib/src/components/molecules/block_table.dart index a31e843..89d24c1 100644 --- a/lib/src/components/molecules/block_table.dart +++ b/lib/src/components/molecules/block_table.dart @@ -403,7 +403,7 @@ class _BlockTable extends State with WidgetsBindingObserver { color: isSelected ? themeData.colorScheme.secondary.withLight( 0.1, - background: themeData.colorScheme.background, + background: themeData.colorScheme.surface, ) : tableCell.backgroundColor ?? themeData.colorScheme.surface, ), diff --git a/lib/src/components/molecules/bottom_sheet.dart b/lib/src/components/molecules/bottom_sheet.dart index 423cfca..ab528bf 100644 --- a/lib/src/components/molecules/bottom_sheet.dart +++ b/lib/src/components/molecules/bottom_sheet.dart @@ -39,7 +39,7 @@ class ZdsBottomSheet extends StatelessWidget { /// The background color for this bottom sheet. /// - /// Defaults to [ColorScheme.background] + /// Defaults to [ColorScheme.surface] final Color? backgroundColor; /// How high this bottom sheet will be allowed to grow. If not null, it must be greater than 0. The bottom sheet will @@ -56,7 +56,7 @@ class ZdsBottomSheet extends StatelessWidget { @override Widget build(BuildContext context) { final ColorScheme colorScheme = Theme.of(context).colorScheme; - final Color sheetBackgroundColor = backgroundColor ?? colorScheme.background; + final Color sheetBackgroundColor = backgroundColor ?? colorScheme.surface; final Color headerColor = header != null ? colorScheme.surface : sheetBackgroundColor; final _BottomSheetHeader headerWidget = _BottomSheetHeader(bottom: header, backgroundColor: headerColor); final MediaQueryData media = MediaQuery.of(context); diff --git a/lib/src/components/molecules/date_range_picker.dart b/lib/src/components/molecules/date_range_picker.dart index 10862b5..bb34a84 100644 --- a/lib/src/components/molecules/date_range_picker.dart +++ b/lib/src/components/molecules/date_range_picker.dart @@ -1,6 +1,6 @@ // Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. +// found in the LICENSE-3RD-PARTY file. import 'dart:async'; import 'dart:math' as math; diff --git a/lib/src/components/molecules/date_time_picker.dart b/lib/src/components/molecules/date_time_picker.dart index 4442527..f442b9e 100644 --- a/lib/src/components/molecules/date_time_picker.dart +++ b/lib/src/components/molecules/date_time_picker.dart @@ -2,8 +2,6 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:interval_time_picker/interval_time_picker.dart' as interval_picker; - import 'package:intl/intl.dart'; import 'package:zeta_flutter/zeta_flutter.dart'; @@ -13,6 +11,7 @@ import '../../utils/theme/theme.dart'; import '../../utils/tools/controller.dart'; import '../organisms/date_range_picker_tile.dart'; import '../organisms/fiscal_date_picker.dart'; +import 'interval_time_picker.dart' as interval_picker; /// Variants of [ZdsDateTimePicker]. enum DateTimePickerMode { diff --git a/lib/src/components/molecules/interval_time_picker.dart b/lib/src/components/molecules/interval_time_picker.dart new file mode 100644 index 0000000..4c8c473 --- /dev/null +++ b/lib/src/components/molecules/interval_time_picker.dart @@ -0,0 +1,2711 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE-3RD-PARTY file. + +// Copyright Copyright 2020-2022 Fleximex. +// See LICENSE-3RD-PARTY file. + +import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +// Examples can assume: +// late BuildContext context; + +const Duration _kDialogSizeAnimationDuration = Duration(milliseconds: 200); +const Duration _kDialAnimateDuration = Duration(milliseconds: 200); +const double _kTwoPi = 2 * math.pi; +const Duration _kVibrateCommitDelay = Duration(milliseconds: 100); + +enum _TimePickerMode { hour, minute } + +const double _kTimePickerHeaderLandscapeWidth = 264; +const double _kTimePickerHeaderControlHeight = 80; + +const double _kTimePickerWidthPortrait = 328; +const double _kTimePickerWidthLandscape = 528; + +const double _kTimePickerHeightInput = 226; +const double _kTimePickerHeightPortrait = 496; +const double _kTimePickerHeightLandscape = 316; + +const double _kTimePickerHeightPortraitCollapsed = 484; +const double _kTimePickerHeightLandscapeCollapsed = 304; + +const BorderRadius _kDefaultBorderRadius = BorderRadius.all(Radius.circular(4)); +const ShapeBorder _kDefaultShape = RoundedRectangleBorder(borderRadius: _kDefaultBorderRadius); + +/// Interactive input mode of the time picker dialog. +/// +/// In [TimePickerEntryMode.dial] mode, a clock dial is displayed and +/// the user taps or drags the time they wish to select. In +/// TimePickerEntryMode.input] mode, [TextField]s are displayed and the user +/// types in the time they wish to select. +enum TimePickerEntryMode { + /// User picks time from a clock dial. + /// + /// Can switch to [input] by activating a mode button in the dialog. + dial, + + /// User can input the time by typing it into text fields. + /// + /// Can switch to [dial] by activating a mode button in the dialog. + input, + + /// User can only pick time from a clock dial. + /// + /// There is no user interface to switch to another mode. + dialOnly, + + /// User can only input the time by typing it into text fields. + /// + /// There is no user interface to switch to another mode. + inputOnly +} + +/// Provides properties for rendering time picker header fragments. +@immutable +class _TimePickerFragmentContext { + const _TimePickerFragmentContext({ + required this.selectedTime, + required this.mode, + required this.onTimeChange, + required this.onModeChange, + required this.onHourDoubleTapped, + required this.onMinuteDoubleTapped, + required this.use24HourDials, + }); + + final TimeOfDay selectedTime; + final _TimePickerMode mode; + final ValueChanged onTimeChange; + final ValueChanged<_TimePickerMode> onModeChange; + final GestureTapCallback onHourDoubleTapped; + final GestureTapCallback onMinuteDoubleTapped; + final bool use24HourDials; +} + +class _TimePickerHeader extends StatelessWidget { + const _TimePickerHeader({ + required this.selectedTime, + required this.mode, + required this.orientation, + required this.onModeChanged, + required this.onChanged, + required this.onHourDoubleTapped, + required this.onMinuteDoubleTapped, + required this.use24HourDials, + required this.helpText, + }); + + final TimeOfDay selectedTime; + final _TimePickerMode mode; + final Orientation orientation; + final ValueChanged<_TimePickerMode> onModeChanged; + final ValueChanged onChanged; + final GestureTapCallback onHourDoubleTapped; + final GestureTapCallback onMinuteDoubleTapped; + final bool use24HourDials; + final String? helpText; + + void _handleChangeMode(_TimePickerMode value) { + if (value != mode) { + onModeChanged(value); + } + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context), 'Asserts that the given context has a [MediaQuery] ancestor.'); + final ThemeData themeData = Theme.of(context); + final TimeOfDayFormat timeOfDayFormat = MaterialLocalizations.of(context).timeOfDayFormat( + alwaysUse24HourFormat: MediaQuery.of(context).alwaysUse24HourFormat, + ); + + final _TimePickerFragmentContext fragmentContext = _TimePickerFragmentContext( + selectedTime: selectedTime, + mode: mode, + onTimeChange: onChanged, + onModeChange: _handleChangeMode, + onHourDoubleTapped: onHourDoubleTapped, + onMinuteDoubleTapped: onMinuteDoubleTapped, + use24HourDials: use24HourDials, + ); + + final EdgeInsets padding; + double? width; + final Widget controls; + + switch (orientation) { + case Orientation.portrait: + // Keep width null because in portrait we don't cap the width. + padding = const EdgeInsets.symmetric(horizontal: 24); + controls = Column( + children: [ + const SizedBox(height: 16), + SizedBox( + height: kMinInteractiveDimension * 2, + child: Row( + children: [ + if (!use24HourDials && timeOfDayFormat == TimeOfDayFormat.a_space_h_colon_mm) ...[ + _DayPeriodControl( + selectedTime: selectedTime, + orientation: orientation, + onChanged: onChanged, + ), + const SizedBox(width: 12), + ], + Expanded( + child: Row( + // Hour/minutes should not change positions in RTL locales. + textDirection: TextDirection.ltr, + children: [ + Expanded(child: _HourControl(fragmentContext: fragmentContext)), + _StringFragment(timeOfDayFormat: timeOfDayFormat), + Expanded(child: _MinuteControl(fragmentContext: fragmentContext)), + ], + ), + ), + if (!use24HourDials && timeOfDayFormat != TimeOfDayFormat.a_space_h_colon_mm) ...[ + const SizedBox(width: 12), + _DayPeriodControl( + selectedTime: selectedTime, + orientation: orientation, + onChanged: onChanged, + ), + ], + ], + ), + ), + ], + ); + case Orientation.landscape: + width = _kTimePickerHeaderLandscapeWidth; + padding = const EdgeInsets.symmetric(horizontal: 24); + controls = Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (!use24HourDials && timeOfDayFormat == TimeOfDayFormat.a_space_h_colon_mm) + _DayPeriodControl( + selectedTime: selectedTime, + orientation: orientation, + onChanged: onChanged, + ), + SizedBox( + height: kMinInteractiveDimension * 2, + child: Row( + // Hour/minutes should not change positions in RTL locales. + textDirection: TextDirection.ltr, + children: [ + Expanded(child: _HourControl(fragmentContext: fragmentContext)), + _StringFragment(timeOfDayFormat: timeOfDayFormat), + Expanded(child: _MinuteControl(fragmentContext: fragmentContext)), + ], + ), + ), + if (!use24HourDials && timeOfDayFormat != TimeOfDayFormat.a_space_h_colon_mm) + _DayPeriodControl( + selectedTime: selectedTime, + orientation: orientation, + onChanged: onChanged, + ), + ], + ), + ); + } + + return Container( + width: width, + padding: padding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + Text( + helpText ?? MaterialLocalizations.of(context).timePickerDialHelpText, + style: TimePickerTheme.of(context).helpTextStyle ?? themeData.textTheme.labelSmall, + ), + controls, + ], + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('selectedTime', selectedTime)) + ..add(EnumProperty<_TimePickerMode>('mode', mode)) + ..add(EnumProperty('orientation', orientation)) + ..add(ObjectFlagProperty>.has('onModeChanged', onModeChanged)) + ..add(ObjectFlagProperty>.has('onChanged', onChanged)) + ..add(ObjectFlagProperty.has('onHourDoubleTapped', onHourDoubleTapped)) + ..add(ObjectFlagProperty.has('onMinuteDoubleTapped', onMinuteDoubleTapped)) + ..add(DiagnosticsProperty('use24HourDials', use24HourDials)) + ..add(StringProperty('helpText', helpText)); + } +} + +class _HourMinuteControl extends StatelessWidget { + const _HourMinuteControl({ + required this.text, + required this.onTap, + required this.onDoubleTap, + required this.isSelected, + }); + + final String text; + final GestureTapCallback onTap; + final GestureTapCallback onDoubleTap; + final bool isSelected; + + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + final TimePickerThemeData timePickerTheme = TimePickerTheme.of(context); + final bool isDark = themeData.colorScheme.brightness == Brightness.dark; + final Color textColor = timePickerTheme.hourMinuteTextColor ?? + WidgetStateColor.resolveWith((Set states) { + return states.contains(WidgetState.selected) + ? themeData.colorScheme.primary + : themeData.colorScheme.onSurface; + }); + final Color backgroundColor = timePickerTheme.hourMinuteColor ?? + WidgetStateColor.resolveWith((Set states) { + return states.contains(WidgetState.selected) + ? themeData.colorScheme.primary.withOpacity(isDark ? 0.24 : 0.12) + : themeData.colorScheme.onSurface.withOpacity(0.12); + }); + final TextStyle style = timePickerTheme.hourMinuteTextStyle ?? themeData.textTheme.displayMedium!; + final ShapeBorder shape = timePickerTheme.hourMinuteShape ?? _kDefaultShape; + + final Set states = isSelected ? {WidgetState.selected} : {}; + return SizedBox( + height: _kTimePickerHeaderControlHeight, + child: Material( + color: WidgetStateProperty.resolveAs(backgroundColor, states), + clipBehavior: Clip.antiAlias, + shape: shape, + child: InkWell( + onTap: onTap, + onDoubleTap: isSelected ? onDoubleTap : null, + child: Center( + child: Text( + text, + style: style.copyWith(color: WidgetStateProperty.resolveAs(textColor, states)), + textScaler: MediaQuery.textScalerOf(context), + ), + ), + ), + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(StringProperty('text', text)) + ..add(ObjectFlagProperty.has('onTap', onTap)) + ..add(ObjectFlagProperty.has('onDoubleTap', onDoubleTap)) + ..add(DiagnosticsProperty('isSelected', isSelected)); + } +} + +/// Displays the hour fragment. +/// +/// When tapped changes time picker dial mode to [_TimePickerMode.hour]. +class _HourControl extends StatelessWidget { + const _HourControl({ + required this.fragmentContext, + }); + + final _TimePickerFragmentContext fragmentContext; + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context), 'Asserts that the given context has a [MediaQuery] ancestor.'); + final bool alwaysUse24HourFormat = MediaQuery.of(context).alwaysUse24HourFormat; + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final String formattedHour = localizations.formatHour( + fragmentContext.selectedTime, + alwaysUse24HourFormat: alwaysUse24HourFormat, + ); + + TimeOfDay hoursFromSelected(int hoursToAdd) { + if (fragmentContext.use24HourDials) { + final int selectedHour = fragmentContext.selectedTime.hour; + return fragmentContext.selectedTime.replacing( + hour: (selectedHour + hoursToAdd) % TimeOfDay.hoursPerDay, + ); + } else { + // Cycle 1 through 12 without changing day period. + final int periodOffset = fragmentContext.selectedTime.periodOffset; + final int hours = fragmentContext.selectedTime.hourOfPeriod; + return fragmentContext.selectedTime.replacing( + hour: periodOffset + (hours + hoursToAdd) % TimeOfDay.hoursPerPeriod, + ); + } + } + + final TimeOfDay nextHour = hoursFromSelected(1); + final String formattedNextHour = localizations.formatHour( + nextHour, + alwaysUse24HourFormat: alwaysUse24HourFormat, + ); + final TimeOfDay previousHour = hoursFromSelected(-1); + final String formattedPreviousHour = localizations.formatHour( + previousHour, + alwaysUse24HourFormat: alwaysUse24HourFormat, + ); + + return Semantics( + value: '${localizations.timePickerHourModeAnnouncement} $formattedHour', + excludeSemantics: true, + increasedValue: formattedNextHour, + onIncrease: () { + fragmentContext.onTimeChange(nextHour); + }, + decreasedValue: formattedPreviousHour, + onDecrease: () { + fragmentContext.onTimeChange(previousHour); + }, + child: _HourMinuteControl( + isSelected: fragmentContext.mode == _TimePickerMode.hour, + text: formattedHour, + onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.hour), context)!, + onDoubleTap: fragmentContext.onHourDoubleTapped, + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty<_TimePickerFragmentContext>('fragmentContext', fragmentContext)); + } +} + +/// A passive fragment showing a string value. +class _StringFragment extends StatelessWidget { + const _StringFragment({ + required this.timeOfDayFormat, + }); + + final TimeOfDayFormat timeOfDayFormat; + + String _stringFragmentValue(TimeOfDayFormat timeOfDayFormat) { + switch (timeOfDayFormat) { + case TimeOfDayFormat.h_colon_mm_space_a: + case TimeOfDayFormat.a_space_h_colon_mm: + case TimeOfDayFormat.H_colon_mm: + case TimeOfDayFormat.HH_colon_mm: + return ':'; + case TimeOfDayFormat.HH_dot_mm: + return '.'; + case TimeOfDayFormat.frenchCanadian: + return 'h'; + } + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final TimePickerThemeData timePickerTheme = TimePickerTheme.of(context); + final TextStyle hourMinuteStyle = timePickerTheme.hourMinuteTextStyle ?? theme.textTheme.displayMedium!; + final Color textColor = timePickerTheme.hourMinuteTextColor ?? theme.colorScheme.onSurface; + + return ExcludeSemantics( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Center( + child: Text( + _stringFragmentValue(timeOfDayFormat), + style: hourMinuteStyle.apply(color: WidgetStateProperty.resolveAs(textColor, {})), + textScaler: MediaQuery.textScalerOf(context), + ), + ), + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(EnumProperty('timeOfDayFormat', timeOfDayFormat)); + } +} + +/// Displays the minute fragment. +/// +/// When tapped changes time picker dial mode to [_TimePickerMode.minute]. +class _MinuteControl extends StatelessWidget { + const _MinuteControl({ + required this.fragmentContext, + }); + + final _TimePickerFragmentContext fragmentContext; + + @override + Widget build(BuildContext context) { + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final String formattedMinute = localizations.formatMinute(fragmentContext.selectedTime); + final TimeOfDay nextMinute = fragmentContext.selectedTime.replacing( + minute: (fragmentContext.selectedTime.minute + 1) % TimeOfDay.minutesPerHour, + ); + final String formattedNextMinute = localizations.formatMinute(nextMinute); + final TimeOfDay previousMinute = fragmentContext.selectedTime.replacing( + minute: (fragmentContext.selectedTime.minute - 1) % TimeOfDay.minutesPerHour, + ); + final String formattedPreviousMinute = localizations.formatMinute(previousMinute); + + return Semantics( + excludeSemantics: true, + value: '${localizations.timePickerMinuteModeAnnouncement} $formattedMinute', + increasedValue: formattedNextMinute, + onIncrease: () { + fragmentContext.onTimeChange(nextMinute); + }, + decreasedValue: formattedPreviousMinute, + onDecrease: () { + fragmentContext.onTimeChange(previousMinute); + }, + child: _HourMinuteControl( + isSelected: fragmentContext.mode == _TimePickerMode.minute, + text: formattedMinute, + onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.minute), context)!, + onDoubleTap: fragmentContext.onMinuteDoubleTapped, + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty<_TimePickerFragmentContext>('fragmentContext', fragmentContext)); + } +} + +/// Displays the am/pm fragment and provides controls for switching between am +/// and pm. +class _DayPeriodControl extends StatelessWidget { + const _DayPeriodControl({ + required this.selectedTime, + required this.onChanged, + required this.orientation, + }); + + final TimeOfDay selectedTime; + final Orientation orientation; + final ValueChanged onChanged; + + void _togglePeriod() { + final int newHour = (selectedTime.hour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay; + final TimeOfDay newTime = selectedTime.replacing(hour: newHour); + onChanged(newTime); + } + + void _setAm(BuildContext context) { + if (selectedTime.period == DayPeriod.am) { + return; + } + switch (Theme.of(context).platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + _announceToAccessibility(context, MaterialLocalizations.of(context).anteMeridiemAbbreviation); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + break; + } + _togglePeriod(); + } + + void _setPm(BuildContext context) { + if (selectedTime.period == DayPeriod.pm) { + return; + } + switch (Theme.of(context).platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + _announceToAccessibility(context, MaterialLocalizations.of(context).postMeridiemAbbreviation); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + break; + } + _togglePeriod(); + } + + @override + Widget build(BuildContext context) { + final MaterialLocalizations materialLocalizations = MaterialLocalizations.of(context); + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final TimePickerThemeData timePickerTheme = TimePickerTheme.of(context); + final bool isDark = colorScheme.brightness == Brightness.dark; + final Color textColor = timePickerTheme.dayPeriodTextColor ?? + WidgetStateColor.resolveWith((Set states) { + return states.contains(WidgetState.selected) ? colorScheme.primary : colorScheme.onSurface.withOpacity(0.60); + }); + final Color backgroundColor = timePickerTheme.dayPeriodColor ?? + WidgetStateColor.resolveWith((Set states) { + // The unselected day period should match the overall picker dialog + // color. Making it transparent enables that without being redundant + // and allows the optional elevation overlay for dark mode to be + // visible. + return states.contains(WidgetState.selected) + ? colorScheme.primary.withOpacity(isDark ? 0.24 : 0.12) + : Colors.transparent; + }); + final bool amSelected = selectedTime.period == DayPeriod.am; + final Set amStates = amSelected ? {WidgetState.selected} : {}; + final bool pmSelected = !amSelected; + final Set pmStates = pmSelected ? {WidgetState.selected} : {}; + final TextStyle textStyle = timePickerTheme.dayPeriodTextStyle ?? Theme.of(context).textTheme.titleMedium!; + final TextStyle amStyle = textStyle.copyWith( + color: WidgetStateProperty.resolveAs(textColor, amStates), + ); + final TextStyle pmStyle = textStyle.copyWith( + color: WidgetStateProperty.resolveAs(textColor, pmStates), + ); + OutlinedBorder shape = + timePickerTheme.dayPeriodShape ?? const RoundedRectangleBorder(borderRadius: _kDefaultBorderRadius); + final BorderSide borderSide = timePickerTheme.dayPeriodBorderSide ?? + BorderSide( + color: Color.alphaBlend(colorScheme.onSurface.withOpacity(0.38), colorScheme.surface), + ); + // Apply the custom borderSide. + shape = shape.copyWith( + side: borderSide, + ); + + const maxScaleFactor = 2.0; + + final Widget amButton = Material( + color: WidgetStateProperty.resolveAs(backgroundColor, amStates), + child: InkWell( + onTap: Feedback.wrapForTap(() => _setAm(context), context), + child: Semantics( + checked: amSelected, + inMutuallyExclusiveGroup: true, + button: true, + child: Center( + child: Text( + materialLocalizations.anteMeridiemAbbreviation, + style: amStyle, + textScaler: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: maxScaleFactor), + ), + ), + ), + ), + ); + + final Widget pmButton = Material( + color: WidgetStateProperty.resolveAs(backgroundColor, pmStates), + child: InkWell( + onTap: Feedback.wrapForTap(() => _setPm(context), context), + child: Semantics( + checked: pmSelected, + inMutuallyExclusiveGroup: true, + button: true, + child: Center( + child: Text( + materialLocalizations.postMeridiemAbbreviation, + style: pmStyle, + textScaler: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: maxScaleFactor), + ), + ), + ), + ), + ); + + final Widget result; + switch (orientation) { + case Orientation.portrait: + const double width = 52; + result = _DayPeriodInputPadding( + minSize: const Size(width, kMinInteractiveDimension * 2), + orientation: orientation, + child: SizedBox( + width: width, + height: _kTimePickerHeaderControlHeight, + child: Material( + clipBehavior: Clip.antiAlias, + color: Colors.transparent, + shape: shape, + child: Column( + children: [ + Expanded(child: amButton), + Container( + decoration: BoxDecoration( + border: Border(top: borderSide), + ), + height: 1, + ), + Expanded(child: pmButton), + ], + ), + ), + ), + ); + case Orientation.landscape: + result = _DayPeriodInputPadding( + minSize: const Size(0, kMinInteractiveDimension), + orientation: orientation, + child: SizedBox( + height: 40, + child: Material( + clipBehavior: Clip.antiAlias, + color: Colors.transparent, + shape: shape, + child: Row( + children: [ + Expanded(child: amButton), + Container( + decoration: BoxDecoration( + border: Border(left: borderSide), + ), + width: 1, + ), + Expanded(child: pmButton), + ], + ), + ), + ), + ); + } + return result; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('selectedTime', selectedTime)) + ..add(EnumProperty('orientation', orientation)) + ..add(ObjectFlagProperty>.has('onChanged', onChanged)); + } +} + +/// A widget to pad the area around the [_DayPeriodControl]'s inner [Material]. +class _DayPeriodInputPadding extends SingleChildRenderObjectWidget { + const _DayPeriodInputPadding({ + required Widget super.child, + required this.minSize, + required this.orientation, + }); + + final Size minSize; + final Orientation orientation; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderInputPadding(minSize, orientation); + } + + @override + void updateRenderObject(BuildContext context, covariant _RenderInputPadding renderObject) { + renderObject.minSize = minSize; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('minSize', minSize)) + ..add(EnumProperty('orientation', orientation)); + } +} + +class _RenderInputPadding extends RenderShiftedBox { + _RenderInputPadding(this._minSize, this.orientation, [RenderBox? child]) : super(child); + + final Orientation orientation; + + Size get minSize => _minSize; + Size _minSize; + set minSize(Size value) { + if (_minSize == value) { + return; + } + _minSize = value; + markNeedsLayout(); + } + + @override + double computeMinIntrinsicWidth(double height) { + if (child != null) { + return math.max(child!.getMinIntrinsicWidth(height), minSize.width); + } + return 0; + } + + @override + double computeMinIntrinsicHeight(double width) { + if (child != null) { + return math.max(child!.getMinIntrinsicHeight(width), minSize.height); + } + return 0; + } + + @override + double computeMaxIntrinsicWidth(double height) { + if (child != null) { + return math.max(child!.getMaxIntrinsicWidth(height), minSize.width); + } + return 0; + } + + @override + double computeMaxIntrinsicHeight(double width) { + if (child != null) { + return math.max(child!.getMaxIntrinsicHeight(width), minSize.height); + } + return 0; + } + + Size _computeSize({required BoxConstraints constraints, required ChildLayouter layoutChild}) { + if (child != null) { + final Size childSize = layoutChild(child!, constraints); + final double width = math.max(childSize.width, minSize.width); + final double height = math.max(childSize.height, minSize.height); + return constraints.constrain(Size(width, height)); + } + return Size.zero; + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + return _computeSize( + constraints: constraints, + layoutChild: ChildLayoutHelper.dryLayoutChild, + ); + } + + @override + void performLayout() { + size = _computeSize( + constraints: constraints, + layoutChild: ChildLayoutHelper.layoutChild, + ); + if (child != null) { + final BoxParentData childParentData = child!.parentData! as BoxParentData; + // ignore: cascade_invocations + childParentData.offset = Alignment.center.alongOffset(size - child!.size as Offset); + } + } + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + if (super.hitTest(result, position: position)) { + return true; + } + + if (position.dx < 0.0 || + position.dx > math.max(child!.size.width, minSize.width) || + position.dy < 0.0 || + position.dy > math.max(child!.size.height, minSize.height)) { + return false; + } + + Offset newPosition = child!.size.center(Offset.zero); + switch (orientation) { + case Orientation.portrait: + if (position.dy > newPosition.dy) { + newPosition += const Offset(0, 1); + } else { + newPosition += const Offset(0, -1); + } + case Orientation.landscape: + if (position.dx > newPosition.dx) { + newPosition += const Offset(1, 0); + } else { + newPosition += const Offset(-1, 0); + } + } + + return result.addWithRawTransform( + transform: MatrixUtils.forceToPoint(newPosition), + position: newPosition, + hitTest: (BoxHitTestResult result, Offset position) { + assert(position == newPosition, 'Asserts that the new position is not the same as the old position'); + return child!.hitTest(result, position: newPosition); + }, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(EnumProperty('orientation', orientation)) + ..add(DiagnosticsProperty('minSize', minSize)); + } +} + +class _TappableLabel { + _TappableLabel({ + required this.value, + required this.painter, + required this.onTap, + }); + + /// The value this label is displaying. + final int value; + + /// Paints the text of the label. + final TextPainter painter; + + /// Called when a tap gesture is detected on the label. + final VoidCallback onTap; +} + +class _DialPainter extends CustomPainter { + _DialPainter({ + required this.primaryLabels, + required this.secondaryLabels, + required this.backgroundColor, + required this.accentColor, + required this.dotColor, + required this.theta, + required this.textDirection, + required this.selectedValue, + }) : super(repaint: PaintingBinding.instance.systemFonts); + + final List<_TappableLabel> primaryLabels; + final List<_TappableLabel> secondaryLabels; + final Color backgroundColor; + final Color accentColor; + final Color dotColor; + final double theta; + final TextDirection textDirection; + final int selectedValue; + + static const double _labelPadding = 28; + + @override + void paint(Canvas canvas, Size size) { + final double radius = size.shortestSide / 2.0; + final Offset center = Offset(size.width / 2.0, size.height / 2.0); + final Offset centerPoint = center; + canvas.drawCircle(centerPoint, radius, Paint()..color = backgroundColor); + + final double labelRadius = radius - _labelPadding; + Offset getOffsetForTheta(double theta) { + return center + Offset(labelRadius * math.cos(theta), -labelRadius * math.sin(theta)); + } + + void paintLabels(List<_TappableLabel>? labels) { + if (labels == null) { + return; + } + final double labelThetaIncrement = -_kTwoPi / labels.length; + double labelTheta = math.pi / 2.0; + + for (final _TappableLabel label in labels) { + final TextPainter labelPainter = label.painter; + final Offset labelOffset = Offset(-labelPainter.width / 2.0, -labelPainter.height / 2.0); + labelPainter.paint(canvas, getOffsetForTheta(labelTheta) + labelOffset); + labelTheta += labelThetaIncrement; + } + } + + paintLabels(primaryLabels); + + final Paint selectorPaint = Paint()..color = accentColor; + final Offset focusedPoint = getOffsetForTheta(theta); + const double focusedRadius = _labelPadding - 4.0; + canvas + ..drawCircle(centerPoint, 4, selectorPaint) + ..drawCircle(focusedPoint, focusedRadius, selectorPaint); + selectorPaint.strokeWidth = 2.0; + canvas.drawLine(centerPoint, focusedPoint, selectorPaint); + + // Add a dot inside the selector but only when it isn't over the labels. + // This checks that the selector's theta is between two labels. A remainder + // between 0.1 and 0.45 indicates that the selector is roughly not above any + // labels. The values were derived by manually testing the dial. + final double labelThetaIncrement = -_kTwoPi / primaryLabels.length; + if (theta % labelThetaIncrement > 0.1 && theta % labelThetaIncrement < 0.45) { + canvas.drawCircle(focusedPoint, 2, selectorPaint..color = dotColor); + } + + final Rect focusedRect = Rect.fromCircle( + center: focusedPoint, + radius: focusedRadius, + ); + canvas + ..save() + ..clipPath(Path()..addOval(focusedRect)); + paintLabels(secondaryLabels); + canvas.restore(); + } + + @override + bool shouldRepaint(_DialPainter oldPainter) { + return oldPainter.primaryLabels != primaryLabels || + oldPainter.secondaryLabels != secondaryLabels || + oldPainter.backgroundColor != backgroundColor || + oldPainter.accentColor != accentColor || + oldPainter.theta != theta; + } +} + +class _Dial extends StatefulWidget { + const _Dial({ + required this.selectedTime, + required this.interval, + required this.visibleStep, + required this.mode, + required this.use24HourDials, + required this.onChanged, + required this.onHourSelected, + }); + + final TimeOfDay selectedTime; + final int interval; + final int visibleStep; + final _TimePickerMode mode; + final bool use24HourDials; + final ValueChanged? onChanged; + final VoidCallback? onHourSelected; + + @override + _DialState createState() => _DialState(); + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('selectedTime', selectedTime)) + ..add(IntProperty('interval', interval)) + ..add(IntProperty('visibleStep', visibleStep)) + ..add(EnumProperty<_TimePickerMode>('mode', mode)) + ..add(DiagnosticsProperty('use24HourDials', use24HourDials)) + ..add(ObjectFlagProperty?>.has('onChanged', onChanged)) + ..add(ObjectFlagProperty.has('onHourSelected', onHourSelected)); + } +} + +class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { + @override + void initState() { + super.initState(); + _thetaController = AnimationController( + duration: _kDialAnimateDuration, + vsync: this, + ); + _thetaTween = Tween(begin: _getThetaForTime(widget.selectedTime)); + _theta = _thetaController.drive(CurveTween(curve: Easing.legacy)).drive(_thetaTween) + ..addListener(() => setState(() {/* _theta.value has changed */})); + } + + late ThemeData themeData; + late MaterialLocalizations localizations; + late MediaQueryData media; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + assert(debugCheckHasMediaQuery(context), 'Asserts that the given context has a [MediaQuery] ancestor.'); + themeData = Theme.of(context); + localizations = MaterialLocalizations.of(context); + media = MediaQuery.of(context); + } + + @override + void didUpdateWidget(_Dial oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.mode != oldWidget.mode || widget.selectedTime != oldWidget.selectedTime) { + if (!_dragging) { + _animateTo(_getThetaForTime(widget.selectedTime)); + } + } + } + + @override + void dispose() { + _thetaController.dispose(); + super.dispose(); + } + + late Tween _thetaTween; + late Animation _theta; + late AnimationController _thetaController; + bool _dragging = false; + + static double _nearest(double target, double a, double b) { + return ((target - a).abs() < (target - b).abs()) ? a : b; + } + + void _animateTo(double targetTheta) { + final double currentTheta = _theta.value; + double beginTheta = _nearest(targetTheta, currentTheta, currentTheta + _kTwoPi); + beginTheta = _nearest(targetTheta, beginTheta, currentTheta - _kTwoPi); + _thetaTween + ..begin = beginTheta + ..end = targetTheta; + _thetaController + ..value = 0.0 + ..forward(); + } + + double _getThetaForTime(TimeOfDay time) { + final int hoursFactor = widget.use24HourDials ? TimeOfDay.hoursPerDay : TimeOfDay.hoursPerPeriod; + final double fraction = widget.mode == _TimePickerMode.hour + ? (time.hour / hoursFactor) % hoursFactor + : (time.minute / TimeOfDay.minutesPerHour) % TimeOfDay.minutesPerHour; + return (math.pi / 2.0 - fraction * _kTwoPi) % _kTwoPi; + } + + TimeOfDay _getTimeForTheta(double theta, {bool roundMinutes = false}) { + final double fraction = (0.25 - (theta % _kTwoPi) / _kTwoPi) % 1.0; + if (widget.mode == _TimePickerMode.hour) { + int newHour; + if (widget.use24HourDials) { + newHour = (fraction * TimeOfDay.hoursPerDay).round() % TimeOfDay.hoursPerDay; + } else { + newHour = (fraction * TimeOfDay.hoursPerPeriod).round() % TimeOfDay.hoursPerPeriod; + newHour = newHour + widget.selectedTime.periodOffset; + } + return widget.selectedTime.replacing(hour: newHour); + } else { + int minute = (fraction * TimeOfDay.minutesPerHour).round() % TimeOfDay.minutesPerHour; + minute = + ((minute + (widget.interval / 2).floor()) ~/ widget.interval) * widget.interval % TimeOfDay.minutesPerHour; + return widget.selectedTime.replacing(minute: minute); + } + } + + TimeOfDay _notifyOnChangedIfNeeded({bool roundMinutes = false}) { + final TimeOfDay current = _getTimeForTheta(_theta.value, roundMinutes: roundMinutes); + if (widget.onChanged == null) { + return current; + } + if (current != widget.selectedTime) { + widget.onChanged!(current); + } + return current; + } + + void _updateThetaForPan({bool roundMinutes = false}) { + setState(() { + final Offset offset = _position! - _center!; + double angle = (math.atan2(offset.dx, offset.dy) - math.pi / 2.0) % _kTwoPi; + if (roundMinutes) { + angle = _getThetaForTime(_getTimeForTheta(angle, roundMinutes: roundMinutes)); + } + _thetaTween + ..begin = angle + ..end = angle; // The controller doesn't animate during the pan gesture. + }); + } + + Offset? _position; + Offset? _center; + + void _handlePanStart(DragStartDetails details) { + assert(!_dragging, 'Asserts that dragging is not currently true'); + _dragging = true; + final RenderBox box = context.findRenderObject()! as RenderBox; + _position = box.globalToLocal(details.globalPosition); + _center = box.size.center(Offset.zero); + _updateThetaForPan(); + _notifyOnChangedIfNeeded(); + } + + void _handlePanUpdate(DragUpdateDetails details) { + _position = _position! + details.delta; + _updateThetaForPan(); + _notifyOnChangedIfNeeded(); + } + + void _handlePanEnd(DragEndDetails details) { + assert(_dragging, 'Asserts that dragging is currently true'); + _dragging = false; + _position = null; + _center = null; + _animateTo(_getThetaForTime(widget.selectedTime)); + if (widget.mode == _TimePickerMode.hour) { + widget.onHourSelected?.call(); + } + } + + void _handleTapUp(TapUpDetails details) { + final RenderBox box = context.findRenderObject()! as RenderBox; + _position = box.globalToLocal(details.globalPosition); + _center = box.size.center(Offset.zero); + _updateThetaForPan(roundMinutes: true); + final TimeOfDay newTime = _notifyOnChangedIfNeeded(roundMinutes: true); + if (widget.mode == _TimePickerMode.hour) { + if (widget.use24HourDials) { + _announceToAccessibility(context, localizations.formatDecimal(newTime.hour)); + } else { + _announceToAccessibility(context, localizations.formatDecimal(newTime.hourOfPeriod)); + } + widget.onHourSelected?.call(); + } else { + _announceToAccessibility(context, localizations.formatDecimal(newTime.minute)); + } + _animateTo(_getThetaForTime(_getTimeForTheta(_theta.value, roundMinutes: true))); + _dragging = false; + _position = null; + _center = null; + } + + void _selectHour(int hour) { + _announceToAccessibility(context, localizations.formatDecimal(hour)); + final TimeOfDay time; + if (widget.mode == _TimePickerMode.hour && widget.use24HourDials) { + time = TimeOfDay(hour: hour, minute: widget.selectedTime.minute); + } else { + if (widget.selectedTime.period == DayPeriod.am) { + time = TimeOfDay(hour: hour, minute: widget.selectedTime.minute); + } else { + time = TimeOfDay(hour: hour + TimeOfDay.hoursPerPeriod, minute: widget.selectedTime.minute); + } + } + final double angle = _getThetaForTime(time); + _thetaTween + ..begin = angle + ..end = angle; + _notifyOnChangedIfNeeded(); + } + + void _selectMinute(int minute) { + _announceToAccessibility(context, localizations.formatDecimal(minute)); + final TimeOfDay time = TimeOfDay( + hour: widget.selectedTime.hour, + minute: minute, + ); + final double angle = _getThetaForTime(time); + _thetaTween + ..begin = angle + ..end = angle; + _notifyOnChangedIfNeeded(); + } + + static const List _amHours = [ + TimeOfDay(hour: 12, minute: 0), + TimeOfDay(hour: 1, minute: 0), + TimeOfDay(hour: 2, minute: 0), + TimeOfDay(hour: 3, minute: 0), + TimeOfDay(hour: 4, minute: 0), + TimeOfDay(hour: 5, minute: 0), + TimeOfDay(hour: 6, minute: 0), + TimeOfDay(hour: 7, minute: 0), + TimeOfDay(hour: 8, minute: 0), + TimeOfDay(hour: 9, minute: 0), + TimeOfDay(hour: 10, minute: 0), + TimeOfDay(hour: 11, minute: 0), + ]; + + static const List _twentyFourHours = [ + TimeOfDay(hour: 0, minute: 0), + TimeOfDay(hour: 2, minute: 0), + TimeOfDay(hour: 4, minute: 0), + TimeOfDay(hour: 6, minute: 0), + TimeOfDay(hour: 8, minute: 0), + TimeOfDay(hour: 10, minute: 0), + TimeOfDay(hour: 12, minute: 0), + TimeOfDay(hour: 14, minute: 0), + TimeOfDay(hour: 16, minute: 0), + TimeOfDay(hour: 18, minute: 0), + TimeOfDay(hour: 20, minute: 0), + TimeOfDay(hour: 22, minute: 0), + ]; + + _TappableLabel _buildTappableLabel(TextTheme textTheme, Color color, int value, String label, VoidCallback onTap) { + final TextStyle style = textTheme.bodyLarge!.copyWith(color: color); + return _TappableLabel( + value: value, + painter: TextPainter( + text: TextSpan(style: style, text: label), + textDirection: TextDirection.ltr, + textScaler: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 2), + )..layout(), + onTap: onTap, + ); + } + + List<_TappableLabel> _build24HourRing(TextTheme textTheme, Color color) => <_TappableLabel>[ + for (final TimeOfDay timeOfDay in _twentyFourHours) + _buildTappableLabel( + textTheme, + color, + timeOfDay.hour, + localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat), + () { + _selectHour(timeOfDay.hour); + }, + ), + ]; + + List<_TappableLabel> _build12HourRing(TextTheme textTheme, Color color) => <_TappableLabel>[ + for (final TimeOfDay timeOfDay in _amHours) + _buildTappableLabel( + textTheme, + color, + timeOfDay.hour, + localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat), + () { + _selectHour(timeOfDay.hour); + }, + ), + ]; + + List<_TappableLabel> _buildMinutes(TextTheme textTheme, Color color) { + final minuteMarkerValues = []; + for (int i = 0; i < (TimeOfDay.minutesPerHour / widget.visibleStep).ceil(); i++) { + minuteMarkerValues.add(TimeOfDay(hour: 0, minute: i * widget.visibleStep)); + } + + return <_TappableLabel>[ + for (final TimeOfDay timeOfDay in minuteMarkerValues) + _buildTappableLabel( + textTheme, + color, + timeOfDay.minute, + localizations.formatMinute(timeOfDay), + () { + _selectMinute(timeOfDay.minute); + }, + ), + ]; + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final TimePickerThemeData pickerTheme = TimePickerTheme.of(context); + final Color backgroundColor = pickerTheme.dialBackgroundColor ?? themeData.colorScheme.onSurface.withOpacity(0.12); + final Color accentColor = pickerTheme.dialHandColor ?? themeData.colorScheme.primary; + final Color primaryLabelColor = + WidgetStateProperty.resolveAs(pickerTheme.dialTextColor, {}) ?? themeData.colorScheme.onSurface; + final Color secondaryLabelColor = + WidgetStateProperty.resolveAs(pickerTheme.dialTextColor, {WidgetState.selected}) ?? + themeData.colorScheme.onPrimary; + List<_TappableLabel> primaryLabels; + List<_TappableLabel> secondaryLabels; + final int selectedDialValue; + switch (widget.mode) { + case _TimePickerMode.hour: + if (widget.use24HourDials) { + selectedDialValue = widget.selectedTime.hour; + primaryLabels = _build24HourRing(theme.textTheme, primaryLabelColor); + secondaryLabels = _build24HourRing(theme.textTheme, secondaryLabelColor); + } else { + selectedDialValue = widget.selectedTime.hourOfPeriod; + primaryLabels = _build12HourRing(theme.textTheme, primaryLabelColor); + secondaryLabels = _build12HourRing(theme.textTheme, secondaryLabelColor); + } + case _TimePickerMode.minute: + selectedDialValue = widget.selectedTime.minute; + primaryLabels = _buildMinutes(theme.textTheme, primaryLabelColor); + secondaryLabels = _buildMinutes(theme.textTheme, secondaryLabelColor); + } + + return GestureDetector( + excludeFromSemantics: true, + onPanStart: _handlePanStart, + onPanUpdate: _handlePanUpdate, + onPanEnd: _handlePanEnd, + onTapUp: _handleTapUp, + child: CustomPaint( + key: const ValueKey('time-picker-dial'), + painter: _DialPainter( + selectedValue: selectedDialValue, + primaryLabels: primaryLabels, + secondaryLabels: secondaryLabels, + backgroundColor: backgroundColor, + accentColor: accentColor, + dotColor: theme.colorScheme.surface, + theta: _theta.value, + textDirection: Directionality.of(context), + ), + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('themeData', themeData)) + ..add(DiagnosticsProperty('localizations', localizations)) + ..add(DiagnosticsProperty('media', media)); + } +} + +class _TimePickerInput extends StatefulWidget { + const _TimePickerInput({ + required this.initialSelectedTime, + required this.interval, + required this.helpText, + required this.errorInvalidText, + required this.hourLabelText, + required this.minuteLabelText, + required this.autofocusHour, + required this.autofocusMinute, + required this.onChanged, + this.restorationId, + }); + + /// The time initially selected when the dialog is shown. + final TimeOfDay initialSelectedTime; + + /// The interval to be used. + final int interval; + + /// Optionally provide your own help text to the time picker. + final String? helpText; + + /// Optionally provide your own validation error text. + final String? errorInvalidText; + + /// Optionally provide your own hour label text. + final String? hourLabelText; + + /// Optionally provide your own minute label text. + final String? minuteLabelText; + + final bool? autofocusHour; + + final bool? autofocusMinute; + + final ValueChanged onChanged; + + /// Restoration ID to save and restore the state of the time picker input + /// widget. + /// + /// If it is non-null, the widget will persist and restore its state + /// + /// The state of this widget is persisted in a [RestorationBucket] claimed + /// from the surrounding [RestorationScope] using the provided restoration ID. + final String? restorationId; + + @override + _TimePickerInputState createState() => _TimePickerInputState(); + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('initialSelectedTime', initialSelectedTime)) + ..add(IntProperty('interval', interval)) + ..add(StringProperty('helpText', helpText)) + ..add(StringProperty('errorInvalidText', errorInvalidText)) + ..add(StringProperty('hourLabelText', hourLabelText)) + ..add(StringProperty('minuteLabelText', minuteLabelText)) + ..add(DiagnosticsProperty('autofocusHour', autofocusHour)) + ..add(DiagnosticsProperty('autofocusMinute', autofocusMinute)) + ..add(ObjectFlagProperty>.has('onChanged', onChanged)) + ..add(StringProperty('restorationId', restorationId)); + } +} + +class _TimePickerInputState extends State<_TimePickerInput> with RestorationMixin { + late final RestorableTimeOfDay _selectedTime = RestorableTimeOfDay(widget.initialSelectedTime); + late final int _interval = widget.interval; + final RestorableBool hourHasError = RestorableBool(false); + final RestorableBool minuteHasError = RestorableBool(false); + + @override + String? get restorationId => widget.restorationId; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_selectedTime, 'selected_time'); + registerForRestoration(hourHasError, 'hour_has_error'); + registerForRestoration(minuteHasError, 'minute_has_error'); + } + + int? _parseHour(String? value) { + if (value == null) { + return null; + } + + int? newHour = int.tryParse(value); + if (newHour == null) { + return null; + } + + if (MediaQuery.of(context).alwaysUse24HourFormat) { + if (newHour >= 0 && newHour < 24) { + return newHour; + } + } else { + if (newHour > 0 && newHour < 13) { + if ((_selectedTime.value.period == DayPeriod.pm && newHour != 12) || + (_selectedTime.value.period == DayPeriod.am && newHour == 12)) { + newHour = (newHour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay; + } + return newHour; + } + } + return null; + } + + int? _parseMinute(String? value) { + if (value == null) { + return null; + } + + final int? newMinute = int.tryParse(value); + if (newMinute == null) { + return null; + } + + if (newMinute >= 0 && newMinute < 60 && newMinute % _interval == 0) { + return newMinute; + } + return null; + } + + void _handleHourSavedSubmitted(String? value) { + final int? newHour = _parseHour(value); + if (newHour != null) { + _selectedTime.value = TimeOfDay(hour: newHour, minute: _selectedTime.value.minute); + widget.onChanged(_selectedTime.value); + } + } + + void _handleHourChanged(String value) { + final int? newHour = _parseHour(value); + if (newHour != null && value.length == 2) { + // If a valid hour is typed, move focus to the minute TextField. + FocusScope.of(context).nextFocus(); + } + } + + void _handleMinuteSavedSubmitted(String? value) { + final int? newMinute = _parseMinute(value); + if (newMinute != null) { + _selectedTime.value = TimeOfDay(hour: _selectedTime.value.hour, minute: int.parse(value!)); + widget.onChanged(_selectedTime.value); + } + } + + void _handleDayPeriodChanged(TimeOfDay value) { + _selectedTime.value = value; + widget.onChanged(_selectedTime.value); + } + + String? _validateHour(String? value) { + final int? newHour = _parseHour(value); + setState(() { + hourHasError.value = newHour == null; + }); + // This is used as the validator for the [TextFormField]. + // Returning an empty string allows the field to go into an error state. + // Returning null means no error in the validation of the entered text. + return newHour == null ? '' : null; + } + + String? _validateMinute(String? value) { + final int? newMinute = _parseMinute(value); + setState(() { + minuteHasError.value = newMinute == null; + }); + // This is used as the validator for the [TextFormField]. + // Returning an empty string allows the field to go into an error state. + // Returning null means no error in the validation of the entered text. + return newMinute == null ? '' : null; + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context), 'Asserts that the given context has a [MediaQuery] ancestor.'); + + final MediaQueryData media = MediaQuery.of(context); + final TimeOfDayFormat timeOfDayFormat = + MaterialLocalizations.of(context).timeOfDayFormat(alwaysUse24HourFormat: media.alwaysUse24HourFormat); + final bool use24HourDials = hourFormat(of: timeOfDayFormat) != HourFormat.h; + final ThemeData theme = Theme.of(context); + final TextStyle hourMinuteStyle = TimePickerTheme.of(context).hourMinuteTextStyle ?? theme.textTheme.displayMedium!; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.helpText ?? MaterialLocalizations.of(context).timePickerInputHelpText, + style: TimePickerTheme.of(context).helpTextStyle ?? theme.textTheme.labelSmall, + ), + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!use24HourDials && timeOfDayFormat == TimeOfDayFormat.a_space_h_colon_mm) ...[ + _DayPeriodControl( + selectedTime: _selectedTime.value, + orientation: Orientation.portrait, + onChanged: _handleDayPeriodChanged, + ), + const SizedBox(width: 12), + ], + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + // Hour/minutes should not change positions in RTL locales. + textDirection: TextDirection.ltr, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + _HourTextField( + restorationId: 'hour_text_field', + selectedTime: _selectedTime.value, + style: hourMinuteStyle, + autofocus: widget.autofocusHour, + validator: _validateHour, + onSavedSubmitted: _handleHourSavedSubmitted, + onChanged: _handleHourChanged, + hourLabelText: widget.hourLabelText, + ), + const SizedBox(height: 8), + if (!hourHasError.value && !minuteHasError.value) + ExcludeSemantics( + child: Text( + widget.hourLabelText ?? MaterialLocalizations.of(context).timePickerHourLabel, + style: theme.textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + Container( + margin: const EdgeInsets.only(top: 8), + height: _kTimePickerHeaderControlHeight, + child: _StringFragment(timeOfDayFormat: timeOfDayFormat), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + _MinuteTextField( + restorationId: 'minute_text_field', + selectedTime: _selectedTime.value, + style: hourMinuteStyle, + autofocus: widget.autofocusMinute, + validator: _validateMinute, + onSavedSubmitted: _handleMinuteSavedSubmitted, + minuteLabelText: widget.minuteLabelText, + ), + const SizedBox(height: 8), + if (!hourHasError.value && !minuteHasError.value) + ExcludeSemantics( + child: Text( + widget.minuteLabelText ?? MaterialLocalizations.of(context).timePickerMinuteLabel, + style: theme.textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ), + ), + if (!use24HourDials && timeOfDayFormat != TimeOfDayFormat.a_space_h_colon_mm) ...[ + const SizedBox(width: 12), + _DayPeriodControl( + selectedTime: _selectedTime.value, + orientation: Orientation.portrait, + onChanged: _handleDayPeriodChanged, + ), + ], + ], + ), + if (hourHasError.value || minuteHasError.value) + Text( + widget.errorInvalidText ?? MaterialLocalizations.of(context).invalidTimeLabel, + style: theme.textTheme.bodyMedium!.copyWith(color: theme.colorScheme.error), + ) + else + const SizedBox(height: 2), + ], + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('hourHasError', hourHasError)) + ..add(DiagnosticsProperty('minuteHasError', minuteHasError)); + } +} + +class _HourTextField extends StatelessWidget { + const _HourTextField({ + required this.selectedTime, + required this.style, + required this.autofocus, + required this.validator, + required this.onSavedSubmitted, + required this.onChanged, + required this.hourLabelText, + this.restorationId, + }); + + final TimeOfDay selectedTime; + final TextStyle style; + final bool? autofocus; + final FormFieldValidator validator; + final ValueChanged onSavedSubmitted; + final ValueChanged onChanged; + final String? hourLabelText; + final String? restorationId; + + @override + Widget build(BuildContext context) { + return _HourMinuteTextField( + restorationId: restorationId, + selectedTime: selectedTime, + isHour: true, + autofocus: autofocus, + style: style, + semanticHintText: hourLabelText ?? MaterialLocalizations.of(context).timePickerHourLabel, + validator: validator, + onSavedSubmitted: onSavedSubmitted, + onChanged: onChanged, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('selectedTime', selectedTime)) + ..add(DiagnosticsProperty('style', style)) + ..add(DiagnosticsProperty('autofocus', autofocus)) + ..add(ObjectFlagProperty>.has('validator', validator)) + ..add(ObjectFlagProperty>.has('onSavedSubmitted', onSavedSubmitted)) + ..add(ObjectFlagProperty>.has('onChanged', onChanged)) + ..add(StringProperty('hourLabelText', hourLabelText)) + ..add(StringProperty('restorationId', restorationId)); + } +} + +class _MinuteTextField extends StatelessWidget { + const _MinuteTextField({ + required this.selectedTime, + required this.style, + required this.autofocus, + required this.validator, + required this.onSavedSubmitted, + required this.minuteLabelText, + this.restorationId, + }); + + final TimeOfDay selectedTime; + final TextStyle style; + final bool? autofocus; + final FormFieldValidator validator; + final ValueChanged onSavedSubmitted; + final String? minuteLabelText; + final String? restorationId; + + @override + Widget build(BuildContext context) { + return _HourMinuteTextField( + restorationId: restorationId, + selectedTime: selectedTime, + isHour: false, + autofocus: autofocus, + style: style, + semanticHintText: minuteLabelText ?? MaterialLocalizations.of(context).timePickerMinuteLabel, + validator: validator, + onSavedSubmitted: onSavedSubmitted, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('selectedTime', selectedTime)) + ..add(DiagnosticsProperty('style', style)) + ..add(DiagnosticsProperty('autofocus', autofocus)) + ..add(ObjectFlagProperty>.has('validator', validator)) + ..add(ObjectFlagProperty>.has('onSavedSubmitted', onSavedSubmitted)) + ..add(StringProperty('minuteLabelText', minuteLabelText)) + ..add(StringProperty('restorationId', restorationId)); + } +} + +class _HourMinuteTextField extends StatefulWidget { + const _HourMinuteTextField({ + required this.selectedTime, + required this.isHour, + required this.autofocus, + required this.style, + required this.semanticHintText, + required this.validator, + required this.onSavedSubmitted, + this.restorationId, + this.onChanged, + }); + + final TimeOfDay selectedTime; + final bool isHour; + final bool? autofocus; + final TextStyle style; + final String semanticHintText; + final FormFieldValidator validator; + final ValueChanged onSavedSubmitted; + final ValueChanged? onChanged; + final String? restorationId; + + @override + _HourMinuteTextFieldState createState() => _HourMinuteTextFieldState(); + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('selectedTime', selectedTime)) + ..add(DiagnosticsProperty('isHour', isHour)) + ..add(DiagnosticsProperty('autofocus', autofocus)) + ..add(DiagnosticsProperty('style', style)) + ..add(StringProperty('semanticHintText', semanticHintText)) + ..add(ObjectFlagProperty>.has('validator', validator)) + ..add(ObjectFlagProperty>.has('onSavedSubmitted', onSavedSubmitted)) + ..add(ObjectFlagProperty?>.has('onChanged', onChanged)) + ..add(StringProperty('restorationId', restorationId)); + } +} + +class _HourMinuteTextFieldState extends State<_HourMinuteTextField> with RestorationMixin { + final RestorableTextEditingController controller = RestorableTextEditingController(); + final RestorableBool controllerHasBeenSet = RestorableBool(false); + late FocusNode focusNode; + + @override + void initState() { + super.initState(); + focusNode = FocusNode() + ..addListener(() { + setState(() {}); // Rebuild. + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Only set the text value if it has not been populated with a localized + // version yet. + if (!controllerHasBeenSet.value) { + controllerHasBeenSet.value = true; + controller.value.text = _formattedValue; + } + } + + @override + String? get restorationId => widget.restorationId; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(controller, 'text_editing_controller'); + registerForRestoration(controllerHasBeenSet, 'has_controller_been_set'); + } + + String get _formattedValue { + final bool alwaysUse24HourFormat = MediaQuery.of(context).alwaysUse24HourFormat; + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + return !widget.isHour + ? localizations.formatMinute(widget.selectedTime) + : localizations.formatHour( + widget.selectedTime, + alwaysUse24HourFormat: alwaysUse24HourFormat, + ); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final TimePickerThemeData timePickerTheme = TimePickerTheme.of(context); + final ColorScheme colorScheme = theme.colorScheme; + + final InputDecorationTheme? inputDecorationTheme = timePickerTheme.inputDecorationTheme; + InputDecoration inputDecoration; + if (inputDecorationTheme != null) { + inputDecoration = const InputDecoration().applyDefaults(inputDecorationTheme); + } else { + inputDecoration = InputDecoration( + contentPadding: EdgeInsets.zero, + filled: true, + enabledBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.transparent), + ), + errorBorder: OutlineInputBorder( + borderSide: BorderSide(color: colorScheme.error, width: 2), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: colorScheme.primary, width: 2), + ), + focusedErrorBorder: OutlineInputBorder( + borderSide: BorderSide(color: colorScheme.error, width: 2), + ), + hintStyle: widget.style.copyWith(color: colorScheme.onSurface.withOpacity(0.36)), + // TODO(rami-a): Remove this logic once https://github.com/flutter/flutter/issues/54104 is fixed. + errorStyle: const TextStyle(fontSize: 0, height: 0), // Prevent the error text from appearing. + ); + } + final Color unfocusedFillColor = timePickerTheme.hourMinuteColor ?? colorScheme.onSurface.withOpacity(0.12); + // If screen reader is in use, make the hint text say hours/minutes. + // Otherwise, remove the hint text when focused because the centered cursor + // appears odd above the hint text. + // + // TODO(rami-a): Once https://github.com/flutter/flutter/issues/67571 is + // resolved, remove the window check for semantics being enabled on web. + final String? hintText = + MediaQuery.of(context).accessibleNavigation || View.of(context).platformDispatcher.semanticsEnabled + ? widget.semanticHintText + : (focusNode.hasFocus ? null : _formattedValue); + inputDecoration = inputDecoration.copyWith( + hintText: hintText, + fillColor: focusNode.hasFocus ? Colors.transparent : inputDecorationTheme?.fillColor ?? unfocusedFillColor, + ); + + return SizedBox( + height: _kTimePickerHeaderControlHeight, + child: MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.noScaling), + child: UnmanagedRestorationScope( + bucket: bucket, + child: TextFormField( + restorationId: 'hour_minute_text_form_field', + autofocus: widget.autofocus ?? false, + expands: true, + maxLines: null, + inputFormatters: [ + LengthLimitingTextInputFormatter(2), + ], + focusNode: focusNode, + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + style: widget.style.copyWith(color: timePickerTheme.hourMinuteTextColor ?? colorScheme.onSurface), + controller: controller.value, + decoration: inputDecoration, + validator: widget.validator, + onEditingComplete: () => widget.onSavedSubmitted(controller.value.text), + onSaved: widget.onSavedSubmitted, + onFieldSubmitted: widget.onSavedSubmitted, + onChanged: widget.onChanged, + ), + ), + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('controller', controller)) + ..add(DiagnosticsProperty('controllerHasBeenSet', controllerHasBeenSet)) + ..add(DiagnosticsProperty('focusNode', focusNode)); + } +} + +/// Signature for when the time picker entry mode is changed. +typedef EntryModeChangeCallback = void Function(TimePickerEntryMode); + +/// A Material Design time picker designed to appear inside a popup dialog. +/// +/// Pass this widget to [showDialog]. The value returned by [showDialog] is the +/// selected [TimeOfDay] if the user taps the "OK" button, or null if the user +/// taps the "CANCEL" button. The selected time is reported by calling +/// [Navigator.pop]. +class IntervalTimePickerDialog extends StatefulWidget { + /// Creates a Material Design time picker. + /// + /// [initialTime] must not be null. + const IntervalTimePickerDialog({ + super.key, + required this.initialTime, + this.interval = 1, + this.visibleStep = VisibleStep.fifths, + this.cancelText, + this.confirmText, + this.helpText, + this.errorInvalidText, + this.hourLabelText, + this.minuteLabelText, + this.restorationId, + this.initialEntryMode = TimePickerEntryMode.dial, + this.onEntryModeChanged, + }); + + /// The time initially selected when the dialog is shown. + final TimeOfDay initialTime; + + /// The entry mode for the picker. Whether it's text input or a dial. + final TimePickerEntryMode initialEntryMode; + + /// The interval used for the minutes the user can choose. + /// The default and minimum is 1. The maximum is 60. + final int interval; + + /// The interval for the visible minute labels in the dial. + final VisibleStep visibleStep; + + /// Optionally provide your own text for the cancel button. + /// + /// If null, the button uses [MaterialLocalizations.cancelButtonLabel]. + final String? cancelText; + + /// Optionally provide your own text for the confirm button. + /// + /// If null, the button uses [MaterialLocalizations.okButtonLabel]. + final String? confirmText; + + /// Optionally provide your own help text to the header of the time picker. + final String? helpText; + + /// Optionally provide your own validation error text. + final String? errorInvalidText; + + /// Optionally provide your own hour label text. + final String? hourLabelText; + + /// Optionally provide your own minute label text. + final String? minuteLabelText; + + /// Restoration ID to save and restore the state of the [IntervalTimePickerDialog]. + /// + /// If it is non-null, the time picker will persist and restore the + /// dialog's state. + /// + /// The state of this widget is persisted in a [RestorationBucket] claimed + /// from the surrounding [RestorationScope] using the provided restoration ID. + /// + /// See also: + /// + /// * [RestorationManager], which explains how state restoration works in + /// Flutter. + final String? restorationId; + + /// Callback called when the selected entry mode is changed. + final EntryModeChangeCallback? onEntryModeChanged; + + @override + State createState() => _IntervalTimePickerDialogState(); + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('initialTime', initialTime)) + ..add(EnumProperty('initialEntryMode', initialEntryMode)) + ..add(IntProperty('interval', interval)) + ..add(EnumProperty('visibleStep', visibleStep)) + ..add(StringProperty('cancelText', cancelText)) + ..add(StringProperty('confirmText', confirmText)) + ..add(StringProperty('helpText', helpText)) + ..add(StringProperty('errorInvalidText', errorInvalidText)) + ..add(StringProperty('hourLabelText', hourLabelText)) + ..add(StringProperty('minuteLabelText', minuteLabelText)) + ..add(StringProperty('restorationId', restorationId)) + ..add(ObjectFlagProperty.has('onEntryModeChanged', onEntryModeChanged)); + } +} + +// A restorable [TimePickerEntryMode] value. +// +// This serializes each entry as a unique `int` value. +class _RestorableTimePickerEntryMode extends RestorableValue { + _RestorableTimePickerEntryMode( + TimePickerEntryMode defaultValue, + ) : _defaultValue = defaultValue; + + final TimePickerEntryMode _defaultValue; + + @override + TimePickerEntryMode createDefaultValue() => _defaultValue; + + @override + void didUpdateValue(TimePickerEntryMode? oldValue) { + assert( + debugIsSerializableForRestoration(value.index), + 'Asserts that the provided `object` is serializable for state restoration.', + ); + notifyListeners(); + } + + @override + TimePickerEntryMode fromPrimitives(Object? data) => TimePickerEntryMode.values[data! as int]; + + @override + Object? toPrimitives() => value.index; +} + +// A restorable [_RestorableTimePickerEntryMode] value. +// +// This serializes each entry as a unique `int` value. +class _RestorableTimePickerMode extends RestorableValue<_TimePickerMode> { + _RestorableTimePickerMode( + _TimePickerMode defaultValue, + ) : _defaultValue = defaultValue; + + final _TimePickerMode _defaultValue; + + @override + _TimePickerMode createDefaultValue() => _defaultValue; + + @override + void didUpdateValue(_TimePickerMode? oldValue) { + assert( + debugIsSerializableForRestoration(value.index), + 'Asserts that the provided `object` is serializable for state restoration.', + ); + notifyListeners(); + } + + @override + _TimePickerMode fromPrimitives(Object? data) => _TimePickerMode.values[data! as int]; + + @override + Object? toPrimitives() => value.index; +} + +// A restorable [AutovalidateMode] value. +// +// This serializes each entry as a unique `int` value. +class _RestorableAutovalidateMode extends RestorableValue { + _RestorableAutovalidateMode( + AutovalidateMode defaultValue, + ) : _defaultValue = defaultValue; + + final AutovalidateMode _defaultValue; + + @override + AutovalidateMode createDefaultValue() => _defaultValue; + + @override + void didUpdateValue(AutovalidateMode? oldValue) { + assert( + debugIsSerializableForRestoration(value.index), + 'Asserts that the provided `object` is serializable for state restoration.', + ); + notifyListeners(); + } + + @override + AutovalidateMode fromPrimitives(Object? data) => AutovalidateMode.values[data! as int]; + + @override + Object? toPrimitives() => value.index; +} + +// A restorable [_RestorableTimePickerEntryMode] value. +// +// This serializes each entry as a unique `int` value. +// +// This value can be null. +class _RestorableTimePickerModeN extends RestorableValue<_TimePickerMode?> { + _RestorableTimePickerModeN( + _TimePickerMode? defaultValue, + ) : _defaultValue = defaultValue; + + final _TimePickerMode? _defaultValue; + + @override + _TimePickerMode? createDefaultValue() => _defaultValue; + + @override + void didUpdateValue(_TimePickerMode? oldValue) { + assert( + debugIsSerializableForRestoration(value?.index), + 'Asserts that the provided `object` is serializable for state restoration.', + ); + notifyListeners(); + } + + @override + _TimePickerMode fromPrimitives(Object? data) => _TimePickerMode.values[data! as int]; + + @override + Object? toPrimitives() => value?.index; +} + +class _IntervalTimePickerDialogState extends State with RestorationMixin { + final GlobalKey _formKey = GlobalKey(); + + late final _RestorableTimePickerEntryMode _entryMode = _RestorableTimePickerEntryMode(widget.initialEntryMode); + final _RestorableTimePickerMode _mode = _RestorableTimePickerMode(_TimePickerMode.hour); + final _RestorableTimePickerModeN _lastModeAnnounced = _RestorableTimePickerModeN(null); + final _RestorableAutovalidateMode _autovalidateMode = _RestorableAutovalidateMode(AutovalidateMode.disabled); + final RestorableBoolN _autofocusHour = RestorableBoolN(null); + final RestorableBoolN _autofocusMinute = RestorableBoolN(null); + final RestorableBool _announcedInitialTime = RestorableBool(false); + + late final int _interval; + late final int _visibleStep; + + late final VoidCallback _entryModeListener; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + localizations = MaterialLocalizations.of(context); + _announceInitialTimeOnce(); + _announceModeOnce(); + } + + @override + void initState() { + super.initState(); + _entryModeListener = () => widget.onEntryModeChanged?.call(_entryMode.value); + _entryMode.addListener(_entryModeListener); + _interval = widget.interval; + _visibleStep = _parseVisibleStep(widget.visibleStep); + } + + int _parseVisibleStep(VisibleStep vS) { + switch (vS) { + case VisibleStep.fifths: + return 5; + case VisibleStep.sixths: + return 6; + case VisibleStep.tenths: + return 10; + case VisibleStep.twelfths: + return 12; + case VisibleStep.fifteenths: + return 15; + case VisibleStep.twentieths: + return 20; + case VisibleStep.thirtieths: + return 30; + case VisibleStep.sixtieth: + return 60; + } + } + + @override + String? get restorationId => widget.restorationId; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_entryMode, 'entry_mode'); + registerForRestoration(_mode, 'mode'); + registerForRestoration(_lastModeAnnounced, 'last_mode_announced'); + registerForRestoration(_autovalidateMode, 'autovalidateMode'); + registerForRestoration(_autofocusHour, 'autofocus_hour'); + registerForRestoration(_autofocusMinute, 'autofocus_minute'); + registerForRestoration(_announcedInitialTime, 'announced_initial_time'); + registerForRestoration(_selectedTime, 'selected_time'); + } + + RestorableTimeOfDay get selectedTime => _selectedTime; + late final RestorableTimeOfDay _selectedTime = RestorableTimeOfDay(widget.initialTime); + + Timer? _vibrateTimer; + late MaterialLocalizations localizations; + + void _vibrate() { + switch (Theme.of(context).platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + _vibrateTimer?.cancel(); + _vibrateTimer = Timer(_kVibrateCommitDelay, () { + unawaited(HapticFeedback.vibrate()); + _vibrateTimer = null; + }); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + break; + } + } + + void _handleModeChanged(_TimePickerMode mode) { + _vibrate(); + setState(() { + _mode.value = mode; + _announceModeOnce(); + }); + } + + void _handleEntryModeToggle() { + setState(() { + switch (_entryMode.value) { + case TimePickerEntryMode.dial: + _autovalidateMode.value = AutovalidateMode.disabled; + _entryMode.value = TimePickerEntryMode.input; + case TimePickerEntryMode.input: + _formKey.currentState!.save(); + _autofocusHour.value = false; + _autofocusMinute.value = false; + _entryMode.value = TimePickerEntryMode.dial; + case TimePickerEntryMode.dialOnly: + case TimePickerEntryMode.inputOnly: + FlutterError('Can not change entry mode from $_entryMode'); + } + }); + } + + void _announceModeOnce() { + if (_lastModeAnnounced.value == _mode.value) { + // Already announced it. + return; + } + + switch (_mode.value) { + case _TimePickerMode.hour: + _announceToAccessibility(context, localizations.timePickerHourModeAnnouncement); + case _TimePickerMode.minute: + _announceToAccessibility(context, localizations.timePickerMinuteModeAnnouncement); + } + _lastModeAnnounced.value = _mode.value; + } + + void _announceInitialTimeOnce() { + if (_announcedInitialTime.value) { + return; + } + + final MediaQueryData media = MediaQuery.of(context); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + _announceToAccessibility( + context, + localizations.formatTimeOfDay(widget.initialTime, alwaysUse24HourFormat: media.alwaysUse24HourFormat), + ); + _announcedInitialTime.value = true; + } + + void _handleTimeChanged(TimeOfDay value) { + _vibrate(); + setState(() { + _selectedTime.value = value; + }); + } + + void _handleHourDoubleTapped() { + _autofocusHour.value = true; + _handleEntryModeToggle(); + } + + void _handleMinuteDoubleTapped() { + _autofocusMinute.value = true; + _handleEntryModeToggle(); + } + + void _handleHourSelected() { + setState(() { + _mode.value = _TimePickerMode.minute; + }); + } + + void _handleCancel() { + Navigator.pop(context); + } + + void _handleOk() { + if (_entryMode.value == TimePickerEntryMode.input || _entryMode.value == TimePickerEntryMode.inputOnly) { + final FormState form = _formKey.currentState!; + if (!form.validate()) { + setState(() { + _autovalidateMode.value = AutovalidateMode.always; + }); + return; + } + form.save(); + } + Navigator.pop(context, _selectedTime.value); + } + + Size _dialogSize(BuildContext context) { + final Orientation orientation = MediaQuery.of(context).orientation; + final ThemeData theme = Theme.of(context); + + final double timePickerWidth; + final double timePickerHeight; + switch (_entryMode.value) { + case TimePickerEntryMode.dial: + case TimePickerEntryMode.dialOnly: + switch (orientation) { + case Orientation.portrait: + timePickerWidth = _kTimePickerWidthPortrait; + timePickerHeight = theme.materialTapTargetSize == MaterialTapTargetSize.padded + ? _kTimePickerHeightPortrait + : _kTimePickerHeightPortraitCollapsed; + case Orientation.landscape: + timePickerWidth = _kTimePickerWidthLandscape; + timePickerHeight = theme.materialTapTargetSize == MaterialTapTargetSize.padded + ? _kTimePickerHeightLandscape + : _kTimePickerHeightLandscapeCollapsed; + } + case TimePickerEntryMode.input: + case TimePickerEntryMode.inputOnly: + timePickerWidth = _kTimePickerWidthPortrait; + timePickerHeight = _kTimePickerHeightInput; + } + return Size(timePickerWidth, timePickerHeight); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context), 'Asserts that the given context has a [MediaQuery] ancestor.'); + final MediaQueryData media = MediaQuery.of(context); + final TimeOfDayFormat timeOfDayFormat = + localizations.timeOfDayFormat(alwaysUse24HourFormat: media.alwaysUse24HourFormat); + final bool use24HourDials = hourFormat(of: timeOfDayFormat) != HourFormat.h; + final ThemeData theme = Theme.of(context); + final ShapeBorder shape = TimePickerTheme.of(context).shape ?? _kDefaultShape; + final Orientation orientation = media.orientation; + + final Widget actions = Row( + children: [ + const SizedBox(width: 10), + if (_entryMode.value == TimePickerEntryMode.dial || _entryMode.value == TimePickerEntryMode.input) + IconButton( + color: TimePickerTheme.of(context).entryModeIconColor ?? + theme.colorScheme.onSurface.withOpacity( + theme.colorScheme.brightness == Brightness.dark ? 1.0 : 0.6, + ), + onPressed: _handleEntryModeToggle, + icon: Icon(_entryMode.value == TimePickerEntryMode.dial ? Icons.keyboard : Icons.access_time), + tooltip: _entryMode.value == TimePickerEntryMode.dial + ? MaterialLocalizations.of(context).inputTimeModeButtonLabel + : MaterialLocalizations.of(context).dialModeButtonLabel, + ), + Expanded( + child: Container( + alignment: AlignmentDirectional.centerEnd, + constraints: const BoxConstraints(minHeight: 52), + padding: const EdgeInsets.symmetric(horizontal: 8), + child: OverflowBar( + spacing: 8, + overflowAlignment: OverflowBarAlignment.end, + children: [ + TextButton( + onPressed: _handleCancel, + child: Text(widget.cancelText ?? localizations.cancelButtonLabel), + ), + TextButton( + onPressed: _handleOk, + child: Text(widget.confirmText ?? localizations.okButtonLabel), + ), + ], + ), + ), + ), + ], + ); + + final Widget picker; + switch (_entryMode.value) { + case TimePickerEntryMode.dial: + case TimePickerEntryMode.dialOnly: + final Widget dial = Padding( + padding: orientation == Orientation.portrait + ? const EdgeInsets.symmetric(horizontal: 36, vertical: 24) + : const EdgeInsets.all(24), + child: ExcludeSemantics( + child: AspectRatio( + aspectRatio: 1, + child: _Dial( + interval: _interval, + visibleStep: _visibleStep, + mode: _mode.value, + use24HourDials: use24HourDials, + selectedTime: _selectedTime.value, + onChanged: _handleTimeChanged, + onHourSelected: _handleHourSelected, + ), + ), + ), + ); + + final Widget header = _TimePickerHeader( + selectedTime: _selectedTime.value, + mode: _mode.value, + orientation: orientation, + onModeChanged: _handleModeChanged, + onChanged: _handleTimeChanged, + onHourDoubleTapped: _handleHourDoubleTapped, + onMinuteDoubleTapped: _handleMinuteDoubleTapped, + use24HourDials: use24HourDials, + helpText: widget.helpText, + ); + + switch (orientation) { + case Orientation.portrait: + picker = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + header, + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Dial grows and shrinks with the available space. + Expanded(child: dial), + actions, + ], + ), + ), + ], + ); + case Orientation.landscape: + picker = Column( + children: [ + Expanded( + child: Row( + children: [ + header, + Expanded(child: dial), + ], + ), + ), + actions, + ], + ); + } + case TimePickerEntryMode.input: + case TimePickerEntryMode.inputOnly: + picker = Form( + key: _formKey, + autovalidateMode: _autovalidateMode.value, + child: SingleChildScrollView( + restorationId: 'time_picker_scroll_view', + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _TimePickerInput( + initialSelectedTime: _selectedTime.value, + interval: _interval, + helpText: widget.helpText, + errorInvalidText: widget.errorInvalidText, + hourLabelText: widget.hourLabelText, + minuteLabelText: widget.minuteLabelText, + autofocusHour: _autofocusHour.value, + autofocusMinute: _autofocusMinute.value, + onChanged: _handleTimeChanged, + restorationId: 'time_picker_input', + ), + actions, + ], + ), + ), + ); + } + + final Size dialogSize = _dialogSize(context); + return Dialog( + shape: shape, + backgroundColor: TimePickerTheme.of(context).backgroundColor ?? theme.colorScheme.surface, + insetPadding: EdgeInsets.symmetric( + horizontal: 16, + vertical: (_entryMode.value == TimePickerEntryMode.input || _entryMode.value == TimePickerEntryMode.inputOnly) + ? 0.0 + : 24.0, + ), + child: AnimatedContainer( + width: dialogSize.width, + height: dialogSize.height, + duration: _kDialogSizeAnimationDuration, + curve: Curves.easeIn, + child: picker, + ), + ); + } + + @override + void dispose() { + _vibrateTimer?.cancel(); + _vibrateTimer = null; + _entryMode.removeListener(_entryModeListener); + super.dispose(); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('selectedTime', selectedTime)) + ..add(DiagnosticsProperty('localizations', localizations)); + } +} + +/// Shows a dialog containing a Material Design time picker. +/// +/// The returned Future resolves to the time selected by the user when the user +/// closes the dialog. If the user cancels the dialog, null is returned. +/// +/// {@tool snippet} +/// Show a dialog with [initialTime] equal to the current time. +/// +/// ```dart +/// Future selectedTime = showIntervalTimePicker( +/// initialTime: TimeOfDay.now(), +/// context: context, +/// ); +/// ``` +/// {@end-tool} +/// +/// The [context], [useRootNavigator] and [routeSettings] arguments are passed to +/// [showDialog], the documentation for which discusses how it is used. +/// +/// The [builder] parameter can be used to wrap the dialog widget +/// to add inherited widgets like [Localizations.override], +/// [Directionality], or [MediaQuery]. +/// +/// The `initialEntryMode` parameter can be used to +/// determine the initial time entry selection of the picker (either a clock +/// dial or text input). +/// +/// The [interval] parameter is used for setting the interval used for the TimePicker. +/// The default and minimum value is 1. The maximum is 60. +/// +/// The [visibleStep] parameter is used for which minute labels the TimePicker will show. +/// +/// Optional strings for the [helpText], [cancelText], [errorInvalidText], +/// [hourLabelText], [minuteLabelText] and [confirmText] can be provided to +/// override the default values. +/// +/// {@macro flutter.widgets.RawDialogRoute} +/// +/// By default, the time picker gets its colors from the overall theme's +/// [ColorScheme]. The time picker can be further customized by providing a +/// [TimePickerThemeData] to the overall theme. +/// +/// {@tool snippet} +/// Show a dialog with the text direction overridden to be [TextDirection.rtl]. +/// +/// ```dart +/// Future selectedTimeRTL = showIntervalTimePicker( +/// context: context, +/// initialTime: TimeOfDay.now(), +/// builder: (BuildContext context, Widget? child) { +/// return Directionality( +/// textDirection: TextDirection.rtl, +/// child: child!, +/// ); +/// }, +/// ); +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// Show a dialog with time unconditionally displayed in 24 hour format. +/// +/// ```dart +/// Future selectedTime24Hour = showIntervalTimePicker( +/// context: context, +/// initialTime: const TimeOfDay(hour: 10, minute: 47), +/// builder: (BuildContext context, Widget? child) { +/// return MediaQuery( +/// data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true), +/// child: child!, +/// ); +/// }, +/// ); +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [showDatePicker], which shows a dialog that contains a Material Design +/// date picker. +/// * [TimePickerThemeData], which allows you to customize the colors, +/// typography, and shape of the time picker. +/// * [DisplayFeatureSubScreen], which documents the specifics of how +/// [DisplayFeature]s can split the screen into sub-screens. +Future showIntervalTimePicker({ + required BuildContext context, + required TimeOfDay initialTime, + int interval = 1, + VisibleStep visibleStep = VisibleStep.fifths, + TransitionBuilder? builder, + bool useRootNavigator = true, + TimePickerEntryMode initialEntryMode = TimePickerEntryMode.dial, + String? cancelText, + String? confirmText, + String? helpText, + String? errorInvalidText, + String? hourLabelText, + String? minuteLabelText, + RouteSettings? routeSettings, + EntryModeChangeCallback? onEntryModeChanged, + Offset? anchorPoint, +}) async { + assert(interval >= 1 && interval <= 60, 'Asserts that the interval is inclusive to the range 0 to 60'); + assert( + debugCheckHasMaterialLocalizations(context), + 'Asserts that the given context has a [Localizations] ancestor that contains a [MaterialLocalizations] delegate.', + ); + + final Widget dialog = IntervalTimePickerDialog( + initialTime: initialTime, + interval: interval, + visibleStep: visibleStep, + initialEntryMode: initialEntryMode, + cancelText: cancelText, + confirmText: confirmText, + helpText: helpText, + errorInvalidText: errorInvalidText, + hourLabelText: hourLabelText, + minuteLabelText: minuteLabelText, + onEntryModeChanged: onEntryModeChanged, + ); + return showDialog( + context: context, + useRootNavigator: useRootNavigator, + builder: (BuildContext context) { + return builder == null ? dialog : builder(context, dialog); + }, + routeSettings: routeSettings, + anchorPoint: anchorPoint, + ); +} + +/// Steps shown on time picker. +enum VisibleStep { + /// Every 5 minutes + fifths, + + /// Every 6 minutes + sixths, + + /// Every 10 minutes + tenths, + + /// Every 12 minutes + twelfths, + + /// Every 15 minutes + fifteenths, + + /// Every 20 minutes + twentieths, + + /// Every 30 minutes + thirtieths, + + /// Every hour + sixtieth, +} + +void _announceToAccessibility(BuildContext context, String message) { + unawaited(SemanticsService.announce(message, Directionality.of(context))); +} diff --git a/lib/src/components/molecules/menu_item.dart b/lib/src/components/molecules/menu_item.dart index 7d3ef6d..348e735 100644 --- a/lib/src/components/molecules/menu_item.dart +++ b/lib/src/components/molecules/menu_item.dart @@ -92,7 +92,7 @@ class ZdsMenuItem extends StatelessWidget { ), ], ).textStyle( - themeData.textTheme.bodyLarge?.copyWith(color: themeData.colorScheme.onBackground), + themeData.textTheme.bodyLarge?.copyWith(color: themeData.colorScheme.onSurface), ), ), if (trailing != null) trailing!, diff --git a/lib/src/components/molecules/slidable_list_tile.dart b/lib/src/components/molecules/slidable_list_tile.dart index 067a68b..d6b5024 100644 --- a/lib/src/components/molecules/slidable_list_tile.dart +++ b/lib/src/components/molecules/slidable_list_tile.dart @@ -194,8 +194,8 @@ class _ActionBuilderState extends State<_ActionBuilder> { onPressed: widget.action.onPressed, label: size.height < 60 && widget.action.icon != null ? null : widget.action.label, icon: widget.action.icon, - backgroundColor: widget.action.backgroundColor ?? themeData.colorScheme.background, - foregroundColor: widget.action.foregroundColor ?? themeData.colorScheme.onBackground, + backgroundColor: widget.action.backgroundColor ?? themeData.colorScheme.surface, + foregroundColor: widget.action.foregroundColor ?? themeData.colorScheme.onSurface, autoClose: widget.action.autoclose, spacing: 16, padding: EdgeInsets.zero, diff --git a/lib/src/components/molecules/vertical_nav.dart b/lib/src/components/molecules/vertical_nav.dart index 3d7689b..87a90cf 100644 --- a/lib/src/components/molecules/vertical_nav.dart +++ b/lib/src/components/molecules/vertical_nav.dart @@ -240,7 +240,7 @@ class _SelectedBackground extends StatelessWidget { padding: const EdgeInsets.only(left: 1), decoration: BoxDecoration( gradient: LinearGradient( - colors: [themeData.colorScheme.background, themeData.colorScheme.surface], + colors: [themeData.colorScheme.surface, themeData.colorScheme.surface], ), borderRadius: const BorderRadius.only( topLeft: Radius.circular(4), diff --git a/lib/src/components/organisms/calendar.dart b/lib/src/components/organisms/calendar.dart index a5eee2f..629f245 100644 --- a/lib/src/components/organisms/calendar.dart +++ b/lib/src/components/organisms/calendar.dart @@ -229,7 +229,7 @@ class ZdsCalendar extends StatefulWidget { /// Color of the text on the calendar header /// - /// Defaults to [ColorScheme.onBackground]. + /// Defaults to [ColorScheme.onSurface]. final Color? calendarHeaderTextColor; /// Custom color override for unselected days. @@ -442,10 +442,10 @@ class _ZdsCalendarState extends State { cellMargin: EdgeInsets.all(widget.weekIcons != null && widget.weekIcons!.isNotEmpty ? 5 : 8), todayTextStyle: textTheme, defaultTextStyle: textTheme.copyWith( - color: widget.calendarTextColor ?? Theme.of(context).colorScheme.onBackground, + color: widget.calendarTextColor ?? Theme.of(context).colorScheme.onSurface, ), weekendTextStyle: textTheme.copyWith( - color: widget.calendarTextColor ?? Theme.of(context).colorScheme.onBackground, + color: widget.calendarTextColor ?? Theme.of(context).colorScheme.onSurface, ), holidayDecoration: BoxDecoration( color: zetaColors.warm.surface, @@ -546,7 +546,7 @@ class _ZdsCalendarState extends State { child: Text( _focusedDay.format('MMMM', languageCode), style: Theme.of(context).textTheme.headlineMedium!.copyWith( - color: widget.calendarHeaderTextColor ?? Theme.of(context).colorScheme.onBackground, + color: widget.calendarHeaderTextColor ?? Theme.of(context).colorScheme.onSurface, ), ), ), @@ -595,7 +595,7 @@ class _ZdsCalendarState extends State { child: Text( _focusedDay.format('yyyy', languageCode), style: Theme.of(context).textTheme.headlineMedium!.copyWith( - color: widget.calendarHeaderTextColor ?? Theme.of(context).colorScheme.onBackground, + color: widget.calendarHeaderTextColor ?? Theme.of(context).colorScheme.onSurface, ), ), ), diff --git a/lib/src/components/organisms/file_picker/file_annotations.dart b/lib/src/components/organisms/file_picker/file_annotations.dart index 0d822c5..330866e 100644 --- a/lib/src/components/organisms/file_picker/file_annotations.dart +++ b/lib/src/components/organisms/file_picker/file_annotations.dart @@ -31,7 +31,7 @@ class ZdsImageAnnotationPostProcessor implements ZdsFilePostProcessor { } Future _editFile(BuildContext context, File originalFile) async { - ImageEditor.i18n(ComponentStrings.of(context).getAll()); + ImageEditor.setI18n(ComponentStrings.of(context).getAll()); final bytes = await Navigator.of(context, rootNavigator: true).push( ZdsFadePageRouteBuilder( fullscreenDialog: true, diff --git a/lib/src/components/organisms/file_picker/file_edit.dart b/lib/src/components/organisms/file_picker/file_edit.dart index a592d3f..8d678a9 100644 --- a/lib/src/components/organisms/file_picker/file_edit.dart +++ b/lib/src/components/organisms/file_picker/file_edit.dart @@ -22,7 +22,7 @@ class ZdsFileEditPostProcessor implements ZdsFilePostProcessor { if (file.isImage() && file.content != null) { final File originalFile = File(file.xFilePath); - ImageEditor.i18n(ComponentStrings.of(buildContext.call()).getAll()); + ImageEditor.setI18n(ComponentStrings.of(buildContext.call()).getAll()); final bytes = await Navigator.of(buildContext.call(), rootNavigator: true).push( ZdsFadePageRouteBuilder( fullscreenDialog: true, diff --git a/lib/src/components/organisms/file_picker/giphy_picker.dart b/lib/src/components/organisms/file_picker/giphy_picker.dart index a8627f4..226b167 100644 --- a/lib/src/components/organisms/file_picker/giphy_picker.dart +++ b/lib/src/components/organisms/file_picker/giphy_picker.dart @@ -144,7 +144,7 @@ class _ZdsGiphyPickerState extends State { ] : [], ), - backgroundColor: Theme.of(context).colorScheme.background, + backgroundColor: Theme.of(context).colorScheme.surface, body: LayoutBuilder( builder: (BuildContext context, BoxConstraints box) { return Column( diff --git a/lib/src/components/organisms/file_picker/image_crop.dart b/lib/src/components/organisms/file_picker/image_crop.dart index 06c2bca..61e23a7 100644 --- a/lib/src/components/organisms/file_picker/image_crop.dart +++ b/lib/src/components/organisms/file_picker/image_crop.dart @@ -23,7 +23,7 @@ class ZdsImageCropPostProcessor implements ZdsFilePostProcessor { if (file.isImage() && file.content != null) { // ignore: avoid_dynamic_calls final originalFile = File(file.content.path as String); - ImageEditor.i18n(ComponentStrings.of(buildContext.call()).getAll()); + ImageEditor.setI18n(ComponentStrings.of(buildContext.call()).getAll()); final bytes = await Navigator.of(buildContext.call(), rootNavigator: true).push( ZdsFadePageRouteBuilder( fullscreenDialog: true, diff --git a/lib/src/components/organisms/html_preview/html_body.dart b/lib/src/components/organisms/html_preview/html_body.dart index 56e3ea5..95f6193 100644 --- a/lib/src/components/organisms/html_preview/html_body.dart +++ b/lib/src/components/organisms/html_preview/html_body.dart @@ -128,30 +128,30 @@ class ZdsHtml extends StatelessWidget { height: Height.auto(), width: Width.auto(), border: Border( - bottom: BorderSide(color: colorscheme.onBackground), - left: BorderSide(color: colorscheme.onBackground), - right: BorderSide(color: colorscheme.onBackground), - top: BorderSide(color: colorscheme.onBackground), + bottom: BorderSide(color: colorscheme.onSurface), + left: BorderSide(color: colorscheme.onSurface), + right: BorderSide(color: colorscheme.onSurface), + top: BorderSide(color: colorscheme.onSurface), ), ), 'tr': Style( height: Height.auto(), width: Width.auto(), border: Border( - bottom: BorderSide(color: colorscheme.onBackground, width: 0.5), - left: BorderSide(color: colorscheme.onBackground, width: 0.5), - right: BorderSide(color: colorscheme.onBackground, width: 0.5), - top: BorderSide(color: colorscheme.onBackground, width: 0.5), + bottom: BorderSide(color: colorscheme.onSurface, width: 0.5), + left: BorderSide(color: colorscheme.onSurface, width: 0.5), + right: BorderSide(color: colorscheme.onSurface, width: 0.5), + top: BorderSide(color: colorscheme.onSurface, width: 0.5), ), ), 'th': Style( height: Height.auto(), width: Width.auto(), border: Border( - bottom: BorderSide(color: colorscheme.onBackground, width: 0.5), - left: BorderSide(color: colorscheme.onBackground, width: 0.5), - right: BorderSide(color: colorscheme.onBackground, width: 0.5), - top: BorderSide(color: colorscheme.onBackground, width: 0.5), + bottom: BorderSide(color: colorscheme.onSurface, width: 0.5), + left: BorderSide(color: colorscheme.onSurface, width: 0.5), + right: BorderSide(color: colorscheme.onSurface, width: 0.5), + top: BorderSide(color: colorscheme.onSurface, width: 0.5), ), padding: HtmlPaddings.all(6), ), @@ -161,10 +161,10 @@ class ZdsHtml extends StatelessWidget { padding: HtmlPaddings.all(6), alignment: Alignment.topLeft, border: Border( - bottom: BorderSide(color: colorscheme.onBackground, width: 0.5), - left: BorderSide(color: colorscheme.onBackground, width: 0.5), - right: BorderSide(color: colorscheme.onBackground, width: 0.5), - top: BorderSide(color: colorscheme.onBackground, width: 0.5), + bottom: BorderSide(color: colorscheme.onSurface, width: 0.5), + left: BorderSide(color: colorscheme.onSurface, width: 0.5), + right: BorderSide(color: colorscheme.onSurface, width: 0.5), + top: BorderSide(color: colorscheme.onSurface, width: 0.5), ), ), 'h5': Style(maxLines: maxLines, textOverflow: TextOverflow.ellipsis), diff --git a/lib/src/components/organisms/list_tile.dart b/lib/src/components/organisms/list_tile.dart index c84251f..8dcefbb 100644 --- a/lib/src/components/organisms/list_tile.dart +++ b/lib/src/components/organisms/list_tile.dart @@ -55,7 +55,7 @@ class ZdsListTile extends StatelessWidget { /// Typically a [Text] widget. /// /// The subtitle's default [TextStyle] depends on [TextTheme.bodyMedium] except - /// [TextStyle.color]. The [TextStyle.color] is [ColorScheme.onBackground]. + /// [TextStyle.color]. The [TextStyle.color] is [ColorScheme.onSurface]. final Widget? subtitle; /// A widget to display after the title. diff --git a/lib/src/components/organisms/navigation_menu.dart b/lib/src/components/organisms/navigation_menu.dart index 2404967..92ea9c0 100644 --- a/lib/src/components/organisms/navigation_menu.dart +++ b/lib/src/components/organisms/navigation_menu.dart @@ -63,7 +63,7 @@ class ZdsNavigationMenu extends StatelessWidget { .toList(), ), ), - if (withSpacer) Container(height: 12, color: Theme.of(context).colorScheme.background), + if (withSpacer) Container(height: 12, color: Theme.of(context).colorScheme.surface), ], ), ); diff --git a/lib/src/components/organisms/quill_editor/color_button.dart b/lib/src/components/organisms/quill_editor/color_button.dart index 2e6096a..ea9bc3d 100644 --- a/lib/src/components/organisms/quill_editor/color_button.dart +++ b/lib/src/components/organisms/quill_editor/color_button.dart @@ -1,10 +1,10 @@ -// ignore_for_file: strict_raw_type, public_member_api_docs +// ignore_for_file: strict_raw_type, import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_colorpicker/flutter_colorpicker.dart'; +import 'package:flutter_colorpicker/flutter_colorpicker.dart' hide ColorExtension1; import 'package:flutter_quill/flutter_quill.dart'; import 'package:flutter_quill/translations.dart'; @@ -16,6 +16,7 @@ import '../../../utils/tools/utils.dart'; /// When pressed, this button displays overlay toolbar with /// buttons for each color. class ZdsQuillToolbarColorButton extends StatefulWidget { + /// Constructs a [ZdsQuillToolbarColorButton]. const ZdsQuillToolbarColorButton({ required this.controller, required this.isBackground, @@ -25,11 +26,15 @@ class ZdsQuillToolbarColorButton extends StatefulWidget { /// Is this background color button or font color final bool isBackground; + + /// Quill controller. final QuillController controller; + + /// Options for the color button. See [QuillToolbarColorButtonOptions]. final QuillToolbarColorButtonOptions options; @override - ZdsQuillToolbarColorButtonState createState() => ZdsQuillToolbarColorButtonState(); + State createState() => _ZdsQuillToolbarColorButtonState(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { @@ -41,7 +46,7 @@ class ZdsQuillToolbarColorButton extends StatefulWidget { } } -class ZdsQuillToolbarColorButtonState extends State { +class _ZdsQuillToolbarColorButtonState extends State { late bool _isToggledColor; late bool _isToggledBackground; late bool _isWhite; diff --git a/lib/src/components/organisms/quill_editor/quill_toolbar.dart b/lib/src/components/organisms/quill_editor/quill_toolbar.dart index 754c8b9..1a26713 100644 --- a/lib/src/components/organisms/quill_editor/quill_toolbar.dart +++ b/lib/src/components/organisms/quill_editor/quill_toolbar.dart @@ -1,4 +1,4 @@ -// ignore_for_file: implementation_imports, public_member_api_docs +// ignore_for_file: implementation_imports import 'package:flutter/material.dart'; import 'package:flutter_quill/flutter_quill.dart'; import 'package:flutter_quill/src/widgets/toolbar/buttons/alignment/select_alignment_buttons.dart'; @@ -84,35 +84,94 @@ enum QuillToolbarOption { /// Options for Toolbar buttons enum ToolbarButtons { + /// Quill undo button. undo, + + /// Quill redo button. redo, + + /// Quill fontFamily button. fontFamily, + + /// Quill fontSize button. fontSize, + + /// Quill bold button. bold, + + /// Quill subscript button. subscript, + + /// Quill superscript button. superscript, + + /// Quill italic button. italic, + + /// Quill small button. small, + + /// Quill underline button. underline, + + /// Quill strikeThrough button. strikeThrough, + + /// Quill inlineCode button. inlineCode, + + /// Quill color button. color, + + /// Quill backgroundColor button. backgroundColor, + + /// Quill clearFormat button. clearFormat, + + /// Quill centerAlignment button. centerAlignment, + + /// Quill leftAlignment button. leftAlignment, + + /// Quill rightAlignment button. rightAlignment, + + /// Quill justifyAlignment button. justifyAlignment, + + /// Quill direction button. direction, + + /// Quill headerStyle button. headerStyle, + + /// Quill listNumbers button. listNumbers, + + /// Quill listBullets button. listBullets, + + /// Quill listChecks button. listChecks, + + /// Quill codeBlock button. codeBlock, + + /// Quill quote button. quote, + + /// Quill indentIncrease button. indentIncrease, + + /// Quill indentDecrease button. indentDecrease, + + /// Quill link button. link, + + /// Quill search button. search, } diff --git a/lib/src/utils/theme/theme.dart b/lib/src/utils/theme/theme.dart index 8102ed1..76082c1 100644 --- a/lib/src/utils/theme/theme.dart +++ b/lib/src/utils/theme/theme.dart @@ -11,7 +11,7 @@ ZdsBottomBarThemeData buildZdsBottomBarThemeData(BuildContext context) { shadows: [ BoxShadow( offset: const Offset(0, -1), - color: themeData.colorScheme.onBackground.withOpacity(0.1), + color: themeData.colorScheme.onSurface.withOpacity(0.1), blurRadius: 2, ), ], @@ -354,7 +354,7 @@ extension ThemeExtension on ThemeData { /// Custom theme for [ZdsDateTimePicker]. ThemeData get zdsDateTimePickerTheme { return copyWith( - dialogBackgroundColor: colorScheme.brightness == Brightness.dark ? colorScheme.background : null, + dialogBackgroundColor: colorScheme.brightness == Brightness.dark ? colorScheme.surface : null, colorScheme: colorScheme.copyWith( primary: colorScheme.secondary.withLight(colorScheme.brightness == Brightness.dark ? 0.75 : 1), onPrimary: colorScheme.onSecondary, @@ -504,8 +504,8 @@ extension ThemeExtension on ThemeData { /// /// Should not be used often as buttons typically should not be too small. ThemeData get shrunkenButtonsThemeData { - MaterialStateProperty buildShrunkenButtonPadding() { - return MaterialStateProperty.all(EdgeInsets.zero); + WidgetStateProperty buildShrunkenButtonPadding() { + return WidgetStateProperty.all(EdgeInsets.zero); } return copyWith( @@ -513,7 +513,7 @@ extension ThemeExtension on ThemeData { style: textButtonTheme.style?.copyWith( tapTargetSize: MaterialTapTargetSize.shrinkWrap, padding: buildShrunkenButtonPadding(), - foregroundColor: MaterialStateProperty.all(colorScheme.primary), + foregroundColor: WidgetStateProperty.all(colorScheme.primary), ), ), elevatedButtonTheme: ElevatedButtonThemeData( diff --git a/lib/src/utils/theme/theme_builders/bottom_app_bar_theme.dart b/lib/src/utils/theme/theme_builders/bottom_app_bar_theme.dart index 76e9aba..91c7645 100644 --- a/lib/src/utils/theme/theme_builders/bottom_app_bar_theme.dart +++ b/lib/src/utils/theme/theme_builders/bottom_app_bar_theme.dart @@ -20,7 +20,7 @@ extension ZetaBottomAppBartTheme on ZetaColorScheme { /// The shadow color of the `BottomAppBar` is set to the `onBackground` color of the `ZetaColorScheme`, /// but with an opacity of 0.1. - shadowColor: onBackground.withOpacity(0.1), + shadowColor: onSurface.withOpacity(0.1), /// Padding inside the `BottomAppBar`. This is constant and set to be symmetric both horizontally /// and vertically. When running on the web, vertical padding is 8, otherwise it's 4. diff --git a/lib/src/utils/theme/theme_builders/button_theme.dart b/lib/src/utils/theme/theme_builders/button_theme.dart index d900222..3b19ba5 100644 --- a/lib/src/utils/theme/theme_builders/button_theme.dart +++ b/lib/src/utils/theme/theme_builders/button_theme.dart @@ -8,7 +8,7 @@ import '../../../components/atoms/button.dart' show ZdsButton, ZdsButtonVariant; extension ZetaButtonTheme on ZetaColorScheme { /// Returns a [BorderSide] with no outline. This is meant for buttons /// that should not have any border. - MaterialStateProperty baseButtonBorderSide() => MaterialStateProperty.all(BorderSide.none); + WidgetStateProperty baseButtonBorderSide() => WidgetStateProperty.all(BorderSide.none); /// Provides a standard padding for buttons across this [ZetaColorScheme]. EdgeInsets buttonPadding() => const EdgeInsets.symmetric(horizontal: 24, vertical: 10); @@ -16,10 +16,10 @@ extension ZetaButtonTheme on ZetaColorScheme { /// Provides the border radius for round buttons in this [ZetaColorScheme]. BorderRadius buttonBorderRadius() => const BorderRadius.all(Radius.circular(71)); - /// Returns a [MaterialStateProperty] of [OutlinedBorder] which could + /// Returns a [WidgetStateProperty] of [OutlinedBorder] which could /// be used when round buttons are required in this [ZetaColorScheme]. - MaterialStateProperty buttonCircularShapeBorder() { - return MaterialStateProperty.all( + WidgetStateProperty buttonCircularShapeBorder() { + return WidgetStateProperty.all( RoundedRectangleBorder( borderRadius: buttonBorderRadius(), ), @@ -35,9 +35,9 @@ extension ZetaButtonTheme on ZetaColorScheme { zetaColors: zetaColors, textTheme: textTheme, ).copyWith( - padding: MaterialStateProperty.all(buttonPadding()), + padding: WidgetStateProperty.all(buttonPadding()), shape: buttonCircularShapeBorder(), - elevation: MaterialStateProperty.all(0), + elevation: WidgetStateProperty.all(0), visualDensity: VisualDensity.standard, ), ); @@ -52,9 +52,9 @@ extension ZetaButtonTheme on ZetaColorScheme { zetaColors: zetaColors, textTheme: textTheme, ).copyWith( - padding: MaterialStateProperty.all(buttonPadding()), + padding: WidgetStateProperty.all(buttonPadding()), shape: buttonCircularShapeBorder(), - elevation: MaterialStateProperty.all(0), + elevation: WidgetStateProperty.all(0), visualDensity: VisualDensity.standard, ), ); @@ -69,9 +69,9 @@ extension ZetaButtonTheme on ZetaColorScheme { zetaColors: zetaColors, textTheme: textTheme, ).copyWith( - padding: MaterialStateProperty.all(buttonPadding()), + padding: WidgetStateProperty.all(buttonPadding()), shape: buttonCircularShapeBorder(), - elevation: MaterialStateProperty.all(0), + elevation: WidgetStateProperty.all(0), visualDensity: VisualDensity.standard, ), ); diff --git a/lib/src/utils/theme/theme_builders/checkbox_theme.dart b/lib/src/utils/theme/theme_builders/checkbox_theme.dart index fd3306b..1ff207b 100644 --- a/lib/src/utils/theme/theme_builders/checkbox_theme.dart +++ b/lib/src/utils/theme/theme_builders/checkbox_theme.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:zeta_flutter/zeta_flutter.dart' show ZetaColorExtensions, ZetaColorScheme; -import '../../tools.dart' show materialStatePropertyResolver; +import '../../tools.dart' show widgetStatePropertyResolver; /// This is an extension method on [ZetaColorScheme] which is used to customize the [CheckboxThemeData]. extension ZetaCheckboxTheme on ZetaColorScheme { @@ -14,14 +14,14 @@ extension ZetaCheckboxTheme on ZetaColorScheme { /// overlayColor, materialTapTargetSize and visualDensity. return CheckboxThemeData( /// Setting up custom mouse cursors for different material states. - mouseCursor: materialStatePropertyResolver( + mouseCursor: widgetStatePropertyResolver( hoveredValue: SystemMouseCursors.click, disabledValue: SystemMouseCursors.forbidden, defaultValue: SystemMouseCursors.basic, ), /// Setting up custom fill color for different material states. - fillColor: materialStatePropertyResolver( + fillColor: widgetStatePropertyResolver( selectedValue: zetaColors.secondary, hoveredValue: zetaColors.secondary.hover, focusedValue: zetaColors.secondary.hover, @@ -33,14 +33,14 @@ extension ZetaCheckboxTheme on ZetaColorScheme { side: BorderSide(color: zetaColors.iconDefault, width: 2), /// Setting up custom checkColor for different material states. - checkColor: materialStatePropertyResolver( + checkColor: widgetStatePropertyResolver( selectedValue: onSecondary, hoveredValue: secondary, defaultValue: zetaColors.secondary.onColor, ), /// Setting up custom overlayColor for different material states. - overlayColor: materialStatePropertyResolver( + overlayColor: widgetStatePropertyResolver( hoveredValue: zetaColors.secondary.hover, ), diff --git a/lib/src/utils/theme/theme_builders/list_tile_theme.dart b/lib/src/utils/theme/theme_builders/list_tile_theme.dart index bad3295..502edc9 100644 --- a/lib/src/utils/theme/theme_builders/list_tile_theme.dart +++ b/lib/src/utils/theme/theme_builders/list_tile_theme.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:zeta_flutter/zeta_flutter.dart' show ZetaColorScheme; -import '../../tools.dart' show materialStatePropertyResolver; +import '../../tools.dart' show widgetStatePropertyResolver; /// This extension on [ZetaColorScheme] allows to create and customize [ListTileThemeData]. extension ListTileExtension on ZetaColorScheme { @@ -38,7 +38,7 @@ extension ListTileExtension on ZetaColorScheme { leadingAndTrailingTextStyle: textTheme.bodySmall?.copyWith(color: zetaColors.textSubtle), /// Setting up custom mouse cursors for different material states. - mouseCursor: materialStatePropertyResolver( + mouseCursor: widgetStatePropertyResolver( hoveredValue: SystemMouseCursors.click, disabledValue: SystemMouseCursors.forbidden, defaultValue: SystemMouseCursors.basic, diff --git a/lib/src/utils/theme/theme_builders/radio_theme.dart b/lib/src/utils/theme/theme_builders/radio_theme.dart index a4dd2fe..1d05d41 100644 --- a/lib/src/utils/theme/theme_builders/radio_theme.dart +++ b/lib/src/utils/theme/theme_builders/radio_theme.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:zeta_flutter/zeta_flutter.dart' show ZetaColorScheme; -import '../../tools/utils.dart' show materialStatePropertyResolver; +import '../../tools/utils.dart' show widgetStatePropertyResolver; /// An extension on [ZetaColorScheme]. /// @@ -12,19 +12,19 @@ extension RadioExtension on ZetaColorScheme { /// /// Mouse cursors, fill color, overlay color, tap target size, and visual density /// of radio buttons can be customized through this method. - /// [MouseCursor] and Color values are resolved using materialStatePropertyResolver that handles + /// [MouseCursor] and Color values are resolved using WidgetStatePropertyResolver that handles /// different UI states of radio button (like hover, disabled etc.). RadioThemeData radioThemeData() { return RadioThemeData( /// Defines the mouse cursor when hovered, disabled and the default value. - mouseCursor: materialStatePropertyResolver( + mouseCursor: widgetStatePropertyResolver( hoveredValue: SystemMouseCursors.click, disabledValue: SystemMouseCursors.forbidden, defaultValue: SystemMouseCursors.basic, ), /// Defines the fill color for the different states of radio button. - fillColor: materialStatePropertyResolver( + fillColor: widgetStatePropertyResolver( selectedValue: zetaColors.secondary, hoveredValue: zetaColors.secondary.hover, focusedValue: zetaColors.secondary.hover, @@ -33,7 +33,7 @@ extension RadioExtension on ZetaColorScheme { ), /// Defines the overlay color for hover state of radio button. - overlayColor: materialStatePropertyResolver( + overlayColor: widgetStatePropertyResolver( hoveredValue: zetaColors.secondary.hover, ), diff --git a/lib/src/utils/theme/theme_builders/search_bar_theme.dart b/lib/src/utils/theme/theme_builders/search_bar_theme.dart index e8a49f6..765ecee 100644 --- a/lib/src/utils/theme/theme_builders/search_bar_theme.dart +++ b/lib/src/utils/theme/theme_builders/search_bar_theme.dart @@ -10,19 +10,19 @@ extension SearchBarExtension on ZetaColorScheme { /// /// Text styles, color and elevation of the SearchBar can be customized through this method. /// TextStyles are derived from the given TextTheme. - /// MaterialStateProperties are used to create resolvable properties for different UI states.. + /// WidgetStateProperties are used to create resolvable properties for different UI states.. SearchBarThemeData searchBarTheme(TextTheme textTheme) { return SearchBarThemeData( /// Defines the hintStyle - The style of hint text to display in the SearchBar /// when no text has been entered. - hintStyle: MaterialStateProperty.all(textTheme.bodyMedium?.copyWith(color: zetaColors.textSubtle)), + hintStyle: WidgetStateProperty.all(textTheme.bodyMedium?.copyWith(color: zetaColors.textSubtle)), /// Defines the textStyle - The style of text to display in the SearchBar /// when the user has entered text. - textStyle: MaterialStateProperty.all(textTheme.bodyMedium), + textStyle: WidgetStateProperty.all(textTheme.bodyMedium), /// Defines the elevation (shadow size) for the SearchBar - elevation: MaterialStateProperty.all(2), + elevation: WidgetStateProperty.all(2), ); } } diff --git a/lib/src/utils/theme/theme_builders/switch_theme.dart b/lib/src/utils/theme/theme_builders/switch_theme.dart index ec96ae0..e2d4aa2 100644 --- a/lib/src/utils/theme/theme_builders/switch_theme.dart +++ b/lib/src/utils/theme/theme_builders/switch_theme.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:zeta_flutter/zeta_flutter.dart' show ZetaColorScheme; -import '../../tools/utils.dart' show materialStatePropertyResolver; +import '../../tools/utils.dart' show widgetStatePropertyResolver; /// This code defines a Dart extension method for [ZetaColorScheme], which uses Material UI components. /// It provides a new [switchTheme] method that allows for customization of a [SwitchThemeData] according to the [ZetaColorScheme]. @@ -20,19 +20,19 @@ extension SwitchExtension on ZetaColorScheme { /// Returns a [SwitchThemeData] object. SwitchThemeData switchTheme() { return SwitchThemeData( - /// Defines the mouse cursor for different [MaterialState]s. + /// Defines the mouse cursor for different [WidgetState]s. /// /// Hovered state uses [SystemMouseCursors.click], Disabled state /// uses [SystemMouseCursors.forbidden], default state uses /// [SystemMouseCursors.basic]. - mouseCursor: materialStatePropertyResolver( + mouseCursor: widgetStatePropertyResolver( hoveredValue: SystemMouseCursors.click, disabledValue: SystemMouseCursors.forbidden, defaultValue: SystemMouseCursors.basic, ), /// Defines the overlay [Color] for the [Switch] when it's hovered. - overlayColor: materialStatePropertyResolver( + overlayColor: widgetStatePropertyResolver( hoveredValue: zetaColors.secondary, ), diff --git a/lib/src/utils/theme/theme_builders/zeta_theme.dart b/lib/src/utils/theme/theme_builders/zeta_theme.dart index 666b9c1..8b720de 100644 --- a/lib/src/utils/theme/theme_builders/zeta_theme.dart +++ b/lib/src/utils/theme/theme_builders/zeta_theme.dart @@ -43,6 +43,7 @@ enum ZetaAppBarStyle { /// Use scheme background color as the AppBar's themed background color, /// including any blend (surface tint) color it may have. + @Deprecated('Use surface instead. ' 'This feature was deprecated after v3.18.0-0.1.pre.') background, } @@ -66,11 +67,10 @@ extension AppBarColor on ZetaAppBarStyle { // Applying secondary color of color scheme return colorScheme.secondary; case ZetaAppBarStyle.surface: + // ignore: deprecated_member_use_from_same_package + case ZetaAppBarStyle.background: // Applying surface color of color scheme return colorScheme.surface; - case ZetaAppBarStyle.background: - // Applying background color of color scheme - return colorScheme.background; } } } @@ -139,7 +139,7 @@ extension ZetaThemeBuilder on ZetaColorScheme { primaryTextTheme: primaryTextTheme, progressIndicatorTheme: progressIndicatorTheme(), radioTheme: radioThemeData(), - scaffoldBackgroundColor: background, + scaffoldBackgroundColor: surface, searchBarTheme: searchBarTheme(textTheme), shadowColor: zetaColors.borderDisabled.withOpacity(0.7), sliderTheme: sliderTheme(), diff --git a/lib/src/utils/theme/theme_data.dart b/lib/src/utils/theme/theme_data.dart index 6480a70..4fe5c86 100644 --- a/lib/src/utils/theme/theme_data.dart +++ b/lib/src/utils/theme/theme_data.dart @@ -1,3 +1,5 @@ +// ignore_for_file: deprecated_member_use_from_same_package + import 'dart:convert'; import 'package:flutter/material.dart'; diff --git a/lib/src/utils/tools/utils.dart b/lib/src/utils/tools/utils.dart index 05e262f..2309967 100644 --- a/lib/src/utils/tools/utils.dart +++ b/lib/src/utils/tools/utils.dart @@ -211,7 +211,7 @@ int numberOfWeeksInYear(int year) { } /// DateTime extension on [String]. -extension DateTimeParser on String { +extension StringParser on String { /// Creates a [DateTime] from this [DateFormat].template string. DateTime? parseDate([String template = 'MM/dd/yyyy KK:mm a', String locale = 'en_US']) { try { @@ -557,38 +557,38 @@ List rotateArrayLeft(List array, int positions) { return remainingPart + rotatedPart; } -/// Generates a MaterialStateProperty based on given values for different states. -MaterialStateProperty materialStatePropertyResolver({ - // Value when MaterialState is hovered +/// Generates a WidgetStateProperty based on given values for different states. +WidgetStateProperty widgetStatePropertyResolver({ + // Value when WidgetState is hovered T? hoveredValue, - // Value when MaterialState is focused + // Value when WidgetState is focused T? focusedValue, - // Value when MaterialState is pressed + // Value when WidgetState is pressed T? pressedValue, - // Value when MaterialState is dragged + // Value when WidgetState is dragged T? draggedValue, - // Value when MaterialState is selected + // Value when WidgetState is selected T? selectedValue, - // Value when MaterialState is scrolledUnder + // Value when WidgetState is scrolledUnder T? scrolledUnderValue, - // Value when MaterialState is disabled + // Value when WidgetState is disabled T? disabledValue, - // Value when MaterialState is error + // Value when WidgetState is error T? errorValue, // Default value when no state is present T? defaultValue, }) { // The blocks check for each possible state and returns the value // If none of the states is present, default value is returned - return MaterialStateProperty.resolveWith((states) { - if (hoveredValue != null && states.contains(MaterialState.hovered)) return hoveredValue; - if (focusedValue != null && states.contains(MaterialState.focused)) return focusedValue; - if (pressedValue != null && states.contains(MaterialState.pressed)) return pressedValue; - if (draggedValue != null && states.contains(MaterialState.dragged)) return draggedValue; - if (selectedValue != null && states.contains(MaterialState.selected)) return selectedValue; - if (scrolledUnderValue != null && states.contains(MaterialState.scrolledUnder)) return scrolledUnderValue; - if (disabledValue != null && states.contains(MaterialState.disabled)) return disabledValue; - if (errorValue != null && states.contains(MaterialState.error)) return errorValue; + return WidgetStateProperty.resolveWith((states) { + if (hoveredValue != null && states.contains(WidgetState.hovered)) return hoveredValue; + if (focusedValue != null && states.contains(WidgetState.focused)) return focusedValue; + if (pressedValue != null && states.contains(WidgetState.pressed)) return pressedValue; + if (draggedValue != null && states.contains(WidgetState.dragged)) return draggedValue; + if (selectedValue != null && states.contains(WidgetState.selected)) return selectedValue; + if (scrolledUnderValue != null && states.contains(WidgetState.scrolledUnder)) return scrolledUnderValue; + if (disabledValue != null && states.contains(WidgetState.disabled)) return disabledValue; + if (errorValue != null && states.contains(WidgetState.error)) return errorValue; return defaultValue; }); } diff --git a/pubspec.yaml b/pubspec.yaml index 96cb1af..c8ddf92 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -52,8 +52,7 @@ dependencies: http_client_helper: ^3.0.0 image_editor_plus: ^1.0.3 image_picker: ^1.0.2 - interval_time_picker: ^2.0.0+5 - intl: ^0.18.1 + intl: ^0.19.0 just_audio: ^0.9.36 linked_scroll_controller: ^0.2.0 mime: ^1.0.0 @@ -73,7 +72,7 @@ dependencies: video_compress: ^3.1.0 video_player: ^2.7.2 vsc_quill_delta_to_html: ^1.0.3 - zeta_flutter: ^0.8.2 + zeta_flutter: ^0.9.1 dev_dependencies: flutter_test: