Skip to content

Instantly share code, notes, and snippets.

@micimize
Last active December 2, 2022 04:35
Show Gist options
  • Save micimize/7e90ed0dcac3d698a47f27048c72225e to your computer and use it in GitHub Desktop.
Save micimize/7e90ed0dcac3d698a47f27048c72225e to your computer and use it in GitHub Desktop.
Hack pulled out of flutter to customize the time picker dialog
// Copy-pasted from flutter/lib/src/material/time_picker.dart
// and hacked apart into a kind-of-useable composable solution
// until the core team has time and inclination to rework the API.
//
// NOTE: No docs adjusted and I likely broke restoration somehow.
//
// 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.
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui' as ui;
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.0;
const double _kTimePickerHeaderControlHeight = 80.0;
const double _kTimePickerWidthPortrait = 328.0;
const double _kTimePickerWidthLandscape = 528.0;
const double _kTimePickerHeightInput = 226.0;
const double _kTimePickerHeightPortrait = 496.0;
const double _kTimePickerHeightLandscape = 316.0;
const double _kTimePickerHeightPortraitCollapsed = 484.0;
const double _kTimePickerHeightLandscapeCollapsed = 304.0;
const BorderRadius _kDefaultBorderRadius =
BorderRadius.all(Radius.circular(4.0));
const ShapeBorder _kDefaultShape =
RoundedRectangleBorder(borderRadius: _kDefaultBorderRadius);
/// 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<TimeOfDay> 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<TimeOfDay> 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));
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.0);
controls = Column(
children: <Widget>[
const SizedBox(height: 16.0),
SizedBox(
height: kMinInteractiveDimension * 2,
child: Row(
children: <Widget>[
if (!use24HourDials &&
timeOfDayFormat ==
TimeOfDayFormat.a_space_h_colon_mm) ...<Widget>[
_DayPeriodControl(
selectedTime: selectedTime,
orientation: orientation,
onChanged: onChanged,
),
const SizedBox(width: 12.0),
],
Expanded(
child: Row(
// Hour/minutes should not change positions in RTL locales.
textDirection: TextDirection.ltr,
children: <Widget>[
Expanded(
child:
_HourControl(fragmentContext: fragmentContext)),
_StringFragment(timeOfDayFormat: timeOfDayFormat),
Expanded(
child: _MinuteControl(
fragmentContext: fragmentContext)),
],
),
),
if (!use24HourDials &&
timeOfDayFormat !=
TimeOfDayFormat.a_space_h_colon_mm) ...<Widget>[
const SizedBox(width: 12.0),
_DayPeriodControl(
selectedTime: selectedTime,
orientation: orientation,
onChanged: onChanged,
),
],
],
),
),
],
);
break;
case Orientation.landscape:
width = _kTimePickerHeaderLandscapeWidth;
padding = const EdgeInsets.symmetric(horizontal: 24.0);
controls = Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
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: <Widget>[
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,
),
],
),
);
break;
}
return Container(
width: width,
padding: padding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const SizedBox(height: 16.0),
Text(
helpText ??
MaterialLocalizations.of(context).timePickerDialHelpText,
style: TimePickerTheme.of(context).helpTextStyle ??
themeData.textTheme.overline,
),
controls,
],
),
);
}
}
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 ??
MaterialStateColor.resolveWith((Set<MaterialState> states) {
return states.contains(MaterialState.selected)
? themeData.colorScheme.primary
: themeData.colorScheme.onSurface;
});
final Color backgroundColor = timePickerTheme.hourMinuteColor ??
MaterialStateColor.resolveWith((Set<MaterialState> states) {
return states.contains(MaterialState.selected)
? themeData.colorScheme.primary.withOpacity(isDark ? 0.24 : 0.12)
: themeData.colorScheme.onSurface.withOpacity(0.12);
});
final TextStyle style =
timePickerTheme.hourMinuteTextStyle ?? themeData.textTheme.headline2!;
final ShapeBorder shape = timePickerTheme.hourMinuteShape ?? _kDefaultShape;
final Set<MaterialState> states = isSelected
? <MaterialState>{MaterialState.selected}
: <MaterialState>{};
return SizedBox(
height: _kTimePickerHeaderControlHeight,
child: Material(
color: MaterialStateProperty.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: MaterialStateProperty.resolveAs(textColor, states)),
textScaleFactor: 1.0,
),
),
),
),
);
}
}
/// 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));
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,
),
);
}
}
/// 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.headline2!;
final Color textColor =
timePickerTheme.hourMinuteTextColor ?? theme.colorScheme.onSurface;
return ExcludeSemantics(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0),
child: Center(
child: Text(
_stringFragmentValue(timeOfDayFormat),
style: hourMinuteStyle.apply(
color: MaterialStateProperty.resolveAs(
textColor, <MaterialState>{})),
textScaleFactor: 1.0,
),
),
),
);
}
}
/// 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,
),
);
}
}
/// 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<TimeOfDay> 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);
break;
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);
break;
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 ??
MaterialStateColor.resolveWith((Set<MaterialState> states) {
return states.contains(MaterialState.selected)
? colorScheme.primary
: colorScheme.onSurface.withOpacity(0.60);
});
final Color backgroundColor = timePickerTheme.dayPeriodColor ??
MaterialStateColor.resolveWith((Set<MaterialState> 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(MaterialState.selected)
? colorScheme.primary.withOpacity(isDark ? 0.24 : 0.12)
: Colors.transparent;
});
final bool amSelected = selectedTime.period == DayPeriod.am;
final Set<MaterialState> amStates = amSelected
? <MaterialState>{MaterialState.selected}
: <MaterialState>{};
final bool pmSelected = !amSelected;
final Set<MaterialState> pmStates = pmSelected
? <MaterialState>{MaterialState.selected}
: <MaterialState>{};
final TextStyle textStyle = timePickerTheme.dayPeriodTextStyle ??
Theme.of(context).textTheme.subtitle1!;
final TextStyle amStyle = textStyle.copyWith(
color: MaterialStateProperty.resolveAs(textColor, amStates),
);
final TextStyle pmStyle = textStyle.copyWith(
color: MaterialStateProperty.resolveAs(textColor, pmStates),
);
OutlinedBorder shape = timePickerTheme.dayPeriodShape ??
const RoundedRectangleBorder(borderRadius: _kDefaultBorderRadius);
final BorderSide borderSide = timePickerTheme.dayPeriodBorderSide ??
BorderSide(
color: Color.alphaBlend(
colorScheme.onBackground.withOpacity(0.38), colorScheme.surface),
);
// Apply the custom borderSide.
shape = shape.copyWith(
side: borderSide,
);
final double buttonTextScaleFactor =
math.min(MediaQuery.of(context).textScaleFactor, 2.0);
final Widget amButton = Material(
color: MaterialStateProperty.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,
textScaleFactor: buttonTextScaleFactor,
),
),
),
),
);
final Widget pmButton = Material(
color: MaterialStateProperty.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,
textScaleFactor: buttonTextScaleFactor,
),
),
),
),
);
final Widget result;
switch (orientation) {
case Orientation.portrait:
const double width = 52.0;
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: <Widget>[
Expanded(child: amButton),
Container(
decoration: BoxDecoration(
border: Border(top: borderSide),
),
height: 1,
),
Expanded(child: pmButton),
],
),
),
),
);
break;
case Orientation.landscape:
result = _DayPeriodInputPadding(
minSize: const Size(0.0, kMinInteractiveDimension),
orientation: orientation,
child: SizedBox(
height: 40.0,
child: Material(
clipBehavior: Clip.antiAlias,
color: Colors.transparent,
shape: shape,
child: Row(
children: <Widget>[
Expanded(child: amButton),
Container(
decoration: BoxDecoration(
border: Border(left: borderSide),
),
width: 1,
),
Expanded(child: pmButton),
],
),
),
),
);
break;
}
return result;
}
}
/// A widget to pad the area around the [_DayPeriodControl]'s inner [Material].
class _DayPeriodInputPadding extends SingleChildRenderObjectWidget {
const _DayPeriodInputPadding({
Key? key,
required Widget child,
required this.minSize,
required this.orientation,
}) : super(key: key, child: child);
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;
}
}
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.0;
}
@override
double computeMinIntrinsicHeight(double width) {
if (child != null) {
return math.max(child!.getMinIntrinsicHeight(width), minSize.height);
}
return 0.0;
}
@override
double computeMaxIntrinsicWidth(double height) {
if (child != null) {
return math.max(child!.getMaxIntrinsicWidth(height), minSize.width);
}
return 0.0;
}
@override
double computeMaxIntrinsicHeight(double width) {
if (child != null) {
return math.max(child!.getMaxIntrinsicHeight(width), minSize.height);
}
return 0.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;
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.0, 1.0);
} else {
newPosition += const Offset(0.0, -1.0);
}
break;
case Orientation.landscape:
if (position.dx > newPosition.dx) {
newPosition += const Offset(1.0, 0.0);
} else {
newPosition += const Offset(-1.0, 0.0);
}
break;
}
return result.addWithRawTransform(
transform: MatrixUtils.forceToPoint(newPosition),
position: newPosition,
hitTest: (BoxHitTestResult result, Offset position) {
assert(position == newPosition);
return child!.hitTest(result, position: newPosition);
},
);
}
}
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.0;
@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.0, selectorPaint);
canvas.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.0, 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.mode,
required this.use24HourDials,
required this.onChanged,
required this.onHourSelected,
});
final TimeOfDay selectedTime;
final _TimePickerMode mode;
final bool use24HourDials;
final ValueChanged<TimeOfDay>? onChanged;
final VoidCallback? onHourSelected;
@override
_DialState createState() => _DialState();
}
class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
@override
void initState() {
super.initState();
_thetaController = AnimationController(
duration: _kDialAnimateDuration,
vsync: this,
);
_thetaTween = Tween<double>(begin: _getThetaForTime(widget.selectedTime));
_theta = _thetaController
.drive(CurveTween(curve: standardEasing))
.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));
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<double> _thetaTween;
late Animation<double> _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;
if (roundMinutes) {
// Round the minutes to nearest 5 minute interval.
minute = ((minute + 2) ~/ 5) * 5 % 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);
_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);
_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<TimeOfDay> _amHours = <TimeOfDay>[
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<TimeOfDay> _twentyFourHours = <TimeOfDay>[
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.bodyText1!.copyWith(color: color);
final double labelScaleFactor =
math.min(MediaQuery.of(context).textScaleFactor, 2.0);
return _TappableLabel(
value: value,
painter: TextPainter(
text: TextSpan(style: style, text: label),
textDirection: TextDirection.ltr,
textScaleFactor: labelScaleFactor,
)..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) {
const List<TimeOfDay> _minuteMarkerValues = <TimeOfDay>[
TimeOfDay(hour: 0, minute: 0),
TimeOfDay(hour: 0, minute: 5),
TimeOfDay(hour: 0, minute: 10),
TimeOfDay(hour: 0, minute: 15),
TimeOfDay(hour: 0, minute: 20),
TimeOfDay(hour: 0, minute: 25),
TimeOfDay(hour: 0, minute: 30),
TimeOfDay(hour: 0, minute: 35),
TimeOfDay(hour: 0, minute: 40),
TimeOfDay(hour: 0, minute: 45),
TimeOfDay(hour: 0, minute: 50),
TimeOfDay(hour: 0, minute: 55),
];
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.onBackground.withOpacity(0.12);
final Color accentColor =
pickerTheme.dialHandColor ?? themeData.colorScheme.primary;
final Color primaryLabelColor = MaterialStateProperty.resolveAs(
pickerTheme.dialTextColor, <MaterialState>{}) ??
themeData.colorScheme.onSurface;
final Color secondaryLabelColor = MaterialStateProperty.resolveAs(
pickerTheme.dialTextColor,
<MaterialState>{MaterialState.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);
}
break;
case _TimePickerMode.minute:
selectedDialValue = widget.selectedTime.minute;
primaryLabels = _buildMinutes(theme.textTheme, primaryLabelColor);
secondaryLabels = _buildMinutes(theme.textTheme, secondaryLabelColor);
break;
}
return GestureDetector(
excludeFromSemantics: true,
onPanStart: _handlePanStart,
onPanUpdate: _handlePanUpdate,
onPanEnd: _handlePanEnd,
onTapUp: _handleTapUp,
child: CustomPaint(
key: const ValueKey<String>('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),
),
),
);
}
}
class _TimePickerInput extends StatefulWidget {
const _TimePickerInput({
Key? key,
required this.selectedTime,
required this.validate,
required this.helpText,
required this.errorInvalidText,
required this.hourLabelText,
required this.minuteLabelText,
required this.autofocusHour,
required this.autofocusMinute,
required this.onChanged,
this.restorationId,
}) : super(key: key);
/// The time initially selected when the dialog is shown.
final TimeOfDay selectedTime;
/// 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<TimeOfDay> onChanged;
final bool Function() validate;
/// 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();
}
class _TimePickerInputState extends State<_TimePickerInput>
with RestorationMixin {
final RestorableBool hourHasError = RestorableBool(false);
final RestorableBool minuteHasError = RestorableBool(false);
@override
String? get restorationId => widget.restorationId;
@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
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 ((widget.selectedTime.period == DayPeriod.pm && newHour != 12) ||
(widget.selectedTime.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) {
return newMinute;
}
return null;
}
void _handleHourSavedSubmitted(String? value) {
final int? newHour = _parseHour(value);
if (newHour != null) {
widget.onChanged(
TimeOfDay(hour: newHour, minute: widget.selectedTime.minute));
}
}
void _handleHourChanged(String value) {
if (value.isEmpty) {
return;
}
final int? newHour = _parseHour(value);
if (widget.validate() && newHour != null) {
widget.onChanged(
TimeOfDay(hour: newHour, minute: widget.selectedTime.minute));
if (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) {
widget.onChanged(
TimeOfDay(hour: widget.selectedTime.hour, minute: int.parse(value!)),
);
}
}
void _handleMinuteChanged(String value) {
if (value.isEmpty) {
return;
}
final int? newMinute = _parseMinute(value);
if (widget.validate() && newMinute != null) {
widget.onChanged(
TimeOfDay(hour: widget.selectedTime.hour, minute: newMinute));
}
}
void _handleDayPeriodChanged(TimeOfDay value) {
widget.onChanged(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));
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.headline2!;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
widget.helpText ??
MaterialLocalizations.of(context).timePickerInputHelpText,
style: TimePickerTheme.of(context).helpTextStyle ??
theme.textTheme.overline,
),
const SizedBox(height: 16.0),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (!use24HourDials &&
timeOfDayFormat ==
TimeOfDayFormat.a_space_h_colon_mm) ...<Widget>[
_DayPeriodControl(
selectedTime: widget.selectedTime,
orientation: Orientation.portrait,
onChanged: _handleDayPeriodChanged,
),
const SizedBox(width: 12.0),
],
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
// Hour/minutes should not change positions in RTL locales.
textDirection: TextDirection.ltr,
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const SizedBox(height: 8.0),
_HourTextField(
restorationId: 'hour_text_field',
selectedTime: widget.selectedTime,
style: hourMinuteStyle,
autofocus: widget.autofocusHour,
validator: _validateHour,
onSavedSubmitted: _handleHourSavedSubmitted,
onChanged: _handleHourChanged,
hourLabelText: widget.hourLabelText,
),
const SizedBox(height: 8.0),
if (!hourHasError.value && !minuteHasError.value)
ExcludeSemantics(
child: Text(
widget.hourLabelText ??
MaterialLocalizations.of(context)
.timePickerHourLabel,
style: theme.textTheme.caption,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
Container(
margin: const EdgeInsets.only(top: 8.0),
height: _kTimePickerHeaderControlHeight,
child: _StringFragment(timeOfDayFormat: timeOfDayFormat),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const SizedBox(height: 8.0),
_MinuteTextField(
restorationId: 'minute_text_field',
selectedTime: widget.selectedTime,
style: hourMinuteStyle,
autofocus: widget.autofocusMinute,
validator: _validateMinute,
onSavedSubmitted: _handleMinuteSavedSubmitted,
onChanged: _handleMinuteChanged,
minuteLabelText: widget.minuteLabelText,
),
const SizedBox(height: 8.0),
if (!hourHasError.value && !minuteHasError.value)
ExcludeSemantics(
child: Text(
widget.minuteLabelText ??
MaterialLocalizations.of(context)
.timePickerMinuteLabel,
style: theme.textTheme.caption,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
),
if (!use24HourDials &&
timeOfDayFormat !=
TimeOfDayFormat.a_space_h_colon_mm) ...<Widget>[
const SizedBox(width: 12.0),
_DayPeriodControl(
selectedTime: widget.selectedTime,
orientation: Orientation.portrait,
onChanged: _handleDayPeriodChanged,
),
],
],
),
if (hourHasError.value || minuteHasError.value)
Text(
widget.errorInvalidText ??
MaterialLocalizations.of(context).invalidTimeLabel,
style: theme.textTheme.bodyText2!
.copyWith(color: theme.colorScheme.error),
)
else
const SizedBox(height: 2.0),
],
),
);
}
}
class _HourTextField extends StatelessWidget {
const _HourTextField({
Key? key,
required this.selectedTime,
required this.style,
required this.autofocus,
required this.validator,
required this.onSavedSubmitted,
required this.onChanged,
required this.hourLabelText,
this.restorationId,
}) : super(key: key);
final TimeOfDay selectedTime;
final TextStyle style;
final bool? autofocus;
final FormFieldValidator<String> validator;
final ValueChanged<String?> onSavedSubmitted;
final ValueChanged<String> 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,
);
}
}
class _MinuteTextField extends StatelessWidget {
const _MinuteTextField({
Key? key,
required this.selectedTime,
required this.style,
required this.autofocus,
required this.validator,
required this.onSavedSubmitted,
required this.onChanged,
required this.minuteLabelText,
this.restorationId,
}) : super(key: key);
final TimeOfDay selectedTime;
final TextStyle style;
final bool? autofocus;
final FormFieldValidator<String> validator;
final ValueChanged<String?> onSavedSubmitted;
final ValueChanged<String> onChanged;
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,
onChanged: onChanged,
);
}
}
class _HourMinuteTextField extends StatefulWidget {
const _HourMinuteTextField({
Key? key,
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,
}) : super(key: key);
final TimeOfDay selectedTime;
final bool isHour;
final bool? autofocus;
final TextStyle style;
final String semanticHintText;
final FormFieldValidator<String> validator;
final ValueChanged<String?> onSavedSubmitted;
final ValueChanged<String>? onChanged;
final String? restorationId;
@override
_HourMinuteTextFieldState createState() => _HourMinuteTextFieldState();
}
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.0),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: colorScheme.primary, width: 2.0),
),
focusedErrorBorder: OutlineInputBorder(
borderSide: BorderSide(color: colorScheme.error, width: 2.0),
),
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.0,
height: 0.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 ||
ui.window.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(textScaleFactor: 1.0),
child: UnmanagedRestorationScope(
bucket: bucket,
child: TextFormField(
restorationId: 'hour_minute_text_form_field',
autofocus: widget.autofocus ?? false,
expands: true,
maxLines: null,
inputFormatters: <TextInputFormatter>[
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,
),
),
),
);
}
}
/// 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 RawTimePicker extends StatefulWidget {
/// Creates a material time picker.
///
/// [initialTime] must not be null.
const RawTimePicker({
Key? key,
required this.time,
required this.onChanged,
required this.entryMode,
required this.onEntryModeChanged,
this.cancelText,
this.confirmText,
this.helpText,
this.errorInvalidText,
this.hourLabelText,
this.minuteLabelText,
this.restorationId,
}) : super(key: key);
final void Function(TimeOfDay value) onChanged;
/// Callback called when the selected entry mode is changed.
final EntryModeChangeCallback onEntryModeChanged;
/// The time selected when the dialog is shown.
final TimeOfDay time;
/// The entry mode for the picker. Whether it's text input or a dial.
final TimePickerEntryMode entryMode;
/// 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 [TimePickerDialog].
///
/// 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;
@override
State<RawTimePicker> createState() => _RawTimePickerState();
static Size computeSize(BuildContext context, TimePickerEntryMode entryMode) {
final Orientation orientation = MediaQuery.of(context).orientation;
final ThemeData theme = Theme.of(context);
// Constrain the textScaleFactor to prevent layout issues. Since only some
// parts of the time picker scale up with textScaleFactor, we cap the factor
// to 1.1 as that provides enough space to reasonably fit all the content.
final double textScaleFactor =
math.min(MediaQuery.of(context).textScaleFactor, 1.1);
final double timePickerWidth;
final double timePickerHeight;
switch (entryMode) {
case TimePickerEntryMode.dial:
switch (orientation) {
case Orientation.portrait:
timePickerWidth = _kTimePickerWidthPortrait;
timePickerHeight =
theme.materialTapTargetSize == MaterialTapTargetSize.padded
? _kTimePickerHeightPortrait
: _kTimePickerHeightPortraitCollapsed;
break;
case Orientation.landscape:
timePickerWidth = _kTimePickerWidthLandscape * textScaleFactor;
timePickerHeight =
theme.materialTapTargetSize == MaterialTapTargetSize.padded
? _kTimePickerHeightLandscape
: _kTimePickerHeightLandscapeCollapsed;
break;
}
break;
case TimePickerEntryMode.input:
timePickerWidth = _kTimePickerWidthPortrait;
timePickerHeight = _kTimePickerHeightInput;
break;
}
return Size(timePickerWidth, timePickerHeight * textScaleFactor);
}
}
// A restorable [TimePickerEntryMode] value.
//
// This serializes each entry as a unique `int` value.
class _RestorableTimePickerEntryMode
extends RestorableValue<TimePickerEntryMode> {
_RestorableTimePickerEntryMode(
TimePickerEntryMode defaultValue,
) : _defaultValue = defaultValue;
final TimePickerEntryMode _defaultValue;
@override
TimePickerEntryMode createDefaultValue() => _defaultValue;
@override
void didUpdateValue(TimePickerEntryMode? oldValue) {
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) {
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<AutovalidateMode> {
_RestorableAutovalidateMode(
AutovalidateMode defaultValue,
) : _defaultValue = defaultValue;
final AutovalidateMode _defaultValue;
@override
AutovalidateMode createDefaultValue() => _defaultValue;
@override
void didUpdateValue(AutovalidateMode? oldValue) {
assert(debugIsSerializableForRestoration(value.index));
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) {
notifyListeners();
}
@override
_TimePickerMode fromPrimitives(Object? data) =>
_TimePickerMode.values[data! as int];
@override
Object? toPrimitives() => value?.index;
}
class _RawTimePickerState extends State<RawTimePicker> with RestorationMixin {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
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);
@override
void didChangeDependencies() {
super.didChangeDependencies();
localizations = MaterialLocalizations.of(context);
_announceInitialTimeOnce();
_announceModeOnce();
}
@override
String? get restorationId => widget.restorationId;
@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
registerForRestoration(_mode, 'mode');
registerForRestoration(_lastModeAnnounced, 'last_mode_announced');
registerForRestoration(_autovalidateMode, 'autovalidateMode');
registerForRestoration(_autofocusHour, 'autofocus_hour');
registerForRestoration(_autofocusMinute, 'autofocus_minute');
registerForRestoration(_announcedInitialTime, 'announced_initial_time');
}
TimeOfDay get selectedTime => widget.time;
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, () {
HapticFeedback.vibrate();
_vibrateTimer = null;
});
break;
case TargetPlatform.iOS:
case TargetPlatform.macOS:
break;
}
}
void _handleModeChanged(_TimePickerMode mode) {
_vibrate();
setState(() {
_mode.value = mode;
_announceModeOnce();
});
}
void _handleEntryModeToggle() {
setState(() {
switch (widget.entryMode) {
case TimePickerEntryMode.dial:
_autovalidateMode.value = AutovalidateMode.disabled;
widget.onEntryModeChanged(TimePickerEntryMode.input);
break;
case TimePickerEntryMode.input:
_formKey.currentState!.save();
_autofocusHour.value = false;
_autofocusMinute.value = false;
widget.onEntryModeChanged(TimePickerEntryMode.dial);
break;
}
});
}
void _announceModeOnce() {
if (_lastModeAnnounced.value == _mode.value) {
// Already announced it.
return;
}
switch (_mode.value) {
case _TimePickerMode.hour:
_announceToAccessibility(
context, localizations.timePickerHourModeAnnouncement);
break;
case _TimePickerMode.minute:
_announceToAccessibility(
context, localizations.timePickerMinuteModeAnnouncement);
break;
}
_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.time,
alwaysUse24HourFormat: media.alwaysUse24HourFormat),
);
_announcedInitialTime.value = true;
}
void _handleTimeChanged(TimeOfDay value) {
_vibrate();
setState(() {
widget.onChanged(value);
});
}
void _handleHourDoubleTapped() {
_autofocusHour.value = true;
_handleEntryModeToggle();
}
void _handleMinuteDoubleTapped() {
_autofocusMinute.value = true;
_handleEntryModeToggle();
}
void _handleHourSelected() {
setState(() {
_mode.value = _TimePickerMode.minute;
});
}
bool _maybeValidate() {
if (widget.entryMode == TimePickerEntryMode.input) {
final FormState form = _formKey.currentState!;
if (!form.validate()) {
setState(() {
_autovalidateMode.value = AutovalidateMode.always;
});
return false;
}
form.save();
}
return true;
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
final MediaQueryData media = MediaQuery.of(context);
final TimeOfDayFormat timeOfDayFormat = localizations.timeOfDayFormat(
alwaysUse24HourFormat: media.alwaysUse24HourFormat);
final bool use24HourDials = hourFormat(of: timeOfDayFormat) != HourFormat.h;
final Orientation orientation = media.orientation;
final Widget picker;
switch (widget.entryMode) {
case TimePickerEntryMode.dial:
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.0,
child: _Dial(
mode: _mode.value,
use24HourDials: use24HourDials,
selectedTime: selectedTime,
onChanged: _handleTimeChanged,
onHourSelected: _handleHourSelected,
),
),
),
);
final Widget header = _TimePickerHeader(
selectedTime: selectedTime,
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: <Widget>[
header,
Expanded(child: dial),
],
);
break;
case Orientation.landscape:
picker = Row(
children: <Widget>[
header,
Expanded(child: dial),
],
);
break;
}
break;
case TimePickerEntryMode.input:
picker = Form(
key: _formKey,
autovalidateMode: _autovalidateMode.value,
child: SingleChildScrollView(
restorationId: 'time_picker_scroll_view',
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
_TimePickerInput(
selectedTime: selectedTime,
helpText: widget.helpText,
errorInvalidText: widget.errorInvalidText,
hourLabelText: widget.hourLabelText,
minuteLabelText: widget.minuteLabelText,
autofocusHour: _autofocusHour.value,
autofocusMinute: _autofocusMinute.value,
onChanged: _handleTimeChanged,
restorationId: 'time_picker_input',
validate: _maybeValidate,
),
],
),
),
);
break;
}
final Size dialogSize =
RawTimePicker.computeSize(context, widget.entryMode);
return AnimatedContainer(
width: dialogSize.width,
height: dialogSize.height,
duration: _kDialogSizeAnimationDuration,
curve: Curves.easeIn,
child: picker,
);
}
@override
void dispose() {
_vibrateTimer?.cancel();
_vibrateTimer = null;
super.dispose();
}
}
class TimePickerDialogWrapper extends StatelessWidget {
const TimePickerDialogWrapper({
Key? key,
required this.child,
required this.entryMode,
}) : super(key: key);
final Widget child;
final TimePickerEntryMode entryMode;
@override
Widget build(BuildContext context) {
final ShapeBorder shape =
TimePickerTheme.of(context).shape ?? _kDefaultShape;
return Dialog(
shape: shape,
backgroundColor: TimePickerTheme.of(context).backgroundColor ??
Theme.of(context).colorScheme.surface,
insetPadding: EdgeInsets.symmetric(
horizontal: 16.0,
vertical: entryMode == TimePickerEntryMode.input ? 0.0 : 24.0,
),
child: child,
);
}
}
class TimePickerControls extends StatelessWidget {
const TimePickerControls({
Key? key,
required this.onEntryModeChanged,
required this.entryMode,
required this.onSubmit,
required this.onCancel,
}) : super(key: key);
final void Function() onSubmit;
final void Function() onCancel;
final TimePickerEntryMode entryMode;
/// Callback called when the selected entry mode is changed.
final EntryModeChangeCallback onEntryModeChanged;
void _handleEntryModeToggle() =>
onEntryModeChanged(entryMode == TimePickerEntryMode.dial
? TimePickerEntryMode.input
: TimePickerEntryMode.dial);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: <Widget>[
const SizedBox(width: 10.0),
IconButton(
color: TimePickerTheme.of(context).entryModeIconColor ??
theme.colorScheme.onSurface.withOpacity(
theme.colorScheme.brightness == Brightness.dark ? 1.0 : 0.6,
),
onPressed: _handleEntryModeToggle,
icon: Icon(entryMode == TimePickerEntryMode.dial
? Icons.keyboard
: Icons.access_time),
tooltip: entryMode == TimePickerEntryMode.dial
? MaterialLocalizations.of(context).inputTimeModeButtonLabel
: MaterialLocalizations.of(context).dialModeButtonLabel,
),
Expanded(
child: Container(
alignment: AlignmentDirectional.centerEnd,
constraints: const BoxConstraints(minHeight: 52.0),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: OverflowBar(
spacing: 8,
overflowAlignment: OverflowBarAlignment.end,
children: <Widget>[
TextButton(
onPressed: onCancel,
style: TextButton.styleFrom(primary: Colors.grey.shade100),
child: Text('CANCEL'),
),
TextButton(
onPressed: onSubmit,
child: Text('DONE'),
),
],
),
),
),
],
);
}
}
void _announceToAccessibility(BuildContext context, String message) {
SemanticsService.announce(message, Directionality.of(context));
}
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '_raw_flutter_time_picker_hack.dart';
class TimeRangePicker extends HookWidget {
const TimeRangePicker({
Key? key,
required this.range,
}) : super(key: key);
final TimeRange range;
TimeRange? resolve(TimeOfDay newStart, TimeOfDay? newEnd) {
final start = range.start.withTimeOfDay(newStart);
final end =
newEnd == null ? range.end : (range.end ?? start).withTimeOfDay(newEnd);
final newRange = TimeRange(start, end);
return newRange.isAtSameMomentsAs(range) ? null : newRange;
}
@override
Widget build(BuildContext context) {
final entryMode = useState(TimePickerEntryMode.dial);
final start = useState(range.start.asTimeOfDay);
final end = useState(range.end?.asTimeOfDay);
final tabController = useTabController(initialLength: 2);
final dialogSize = RawTimePicker.computeSize(context, entryMode.value);
var initialEnd = end.value ?? DateTime.now().asTimeOfDay;
if (initialEnd.isBefore(start.value)) {
initialEnd = start.value;
}
return TimePickerDialogWrapper(
entryMode: entryMode.value,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TabBar(
controller: tabController,
tabs: [
for (final tab in [
"START (${start.value.fmt()})",
"END" + (end.value != null ? " (${end.value!.fmt()})" : "")
])
Tab(
child: Padding(
padding: EdgeInsets.only(top: 3),
child: Text(tab),
),
),
],
),
AnimatedContainer(
width: dialogSize.width,
height: dialogSize.height,
duration: Duration(milliseconds: 200),
child: TabBarView(
controller: tabController,
physics: NeverScrollableScrollPhysics(),
children: [
RawTimePicker(
time: start.value,
entryMode: entryMode.value,
helpText: "SELECT START TIME",
onChanged: (newStart) => start.value = newStart,
onEntryModeChanged: (e) => entryMode.value = e,
),
RawTimePicker(
time: initialEnd,
entryMode: entryMode.value,
helpText: "SELECT END TIME (OPTIONAL)",
onChanged: (newEnd) => end.value = newEnd,
onEntryModeChanged: (e) => entryMode.value = e,
),
],
),
),
TimePickerControls(
entryMode: entryMode.value,
onEntryModeChanged: (e) => entryMode.value = e,
onCancel: () => Navigator.pop(context),
onSubmit: () =>
Navigator.pop(context, resolve(start.value, end.value)),
)
],
),
);
}
}
extension on TimeOfDay {
String fmt() {
String _addLeadingZeroIfNeeded(int value) {
if (value < 10) {
return '0$value';
}
return value.toString();
}
final String hourLabel = _addLeadingZeroIfNeeded(hour);
final String minuteLabel = _addLeadingZeroIfNeeded(minute);
return '$hourLabel:$minuteLabel';
}
bool isBefore(TimeOfDay other) {
return other.hour > hour || (other.hour == hour && other.minute > minute);
}
}
class TimeRange {
TimeRange(this.start, this.end);
DateTime start;
DateTime? end;
bool isAtSameMomentsAs(TimeRange other) {
if (!start.isAtSameMomentAs(other.start)) {
return false;
}
if ((end ?? other.end) == null) {
return true;
}
if (end == null || other.end == null) {
return false;
}
return end!.isAtSameMomentAs(other.end!);
}
}
extension DateTimeHelpers on DateTime {
TimeOfDay get asTimeOfDay => TimeOfDay(hour: hour, minute: minute);
DateTime withTimeOfDay(TimeOfDay? tod) =>
tod == null ? this : DateTime(year, month, day, tod.hour, tod.minute);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment