2164 lines
68 KiB
Dart
2164 lines
68 KiB
Dart
// 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.
|
|
|
|
// @dart = 2.8
|
|
|
|
import 'dart:async';
|
|
import 'dart:math' as math;
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
|
|
// Examples can assume:
|
|
// 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(10.0));
|
|
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 {
|
|
/// Tapping/dragging on a clock dial.
|
|
dial,
|
|
|
|
/// Text input.
|
|
input,
|
|
}
|
|
|
|
/// 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.use24HourDials,
|
|
}) : assert(selectedTime != null),
|
|
assert(mode != null),
|
|
assert(onTimeChange != null),
|
|
assert(onModeChange != null),
|
|
assert(use24HourDials != null);
|
|
|
|
final TimeOfDay selectedTime;
|
|
final _TimePickerMode mode;
|
|
final ValueChanged<TimeOfDay> onTimeChange;
|
|
final ValueChanged<_TimePickerMode> onModeChange;
|
|
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.use24HourDials,
|
|
@required this.helpText,
|
|
}) : assert(selectedTime != null),
|
|
assert(mode != null),
|
|
assert(orientation != null),
|
|
assert(use24HourDials != null);
|
|
|
|
final TimeOfDay selectedTime;
|
|
final _TimePickerMode mode;
|
|
final Orientation orientation;
|
|
final ValueChanged<_TimePickerMode> onModeChanged;
|
|
final ValueChanged<TimeOfDay> onChanged;
|
|
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 = Theme.of(context);
|
|
final timeOfDayFormat = MaterialLocalizations.of(context).timeOfDayFormat(
|
|
alwaysUse24HourFormat: MediaQuery.of(context).alwaysUse24HourFormat,
|
|
);
|
|
|
|
final fragmentContext = _TimePickerFragmentContext(
|
|
selectedTime: selectedTime,
|
|
mode: mode,
|
|
onTimeChange: onChanged,
|
|
onModeChange: _handleChangeMode,
|
|
use24HourDials: use24HourDials,
|
|
);
|
|
|
|
EdgeInsets padding;
|
|
double width;
|
|
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),
|
|
Container(
|
|
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: _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,
|
|
),
|
|
Container(
|
|
height: kMinInteractiveDimension * 2,
|
|
child: Row(
|
|
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.isSelected,
|
|
}) : assert(text != null),
|
|
assert(onTap != null),
|
|
assert(isSelected != null);
|
|
|
|
final String text;
|
|
final GestureTapCallback onTap;
|
|
final bool isSelected;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final themeData = Theme.of(context);
|
|
final timePickerTheme = TimePickerTheme.of(context);
|
|
final isDark = themeData.colorScheme.brightness == Brightness.dark;
|
|
final textColor = timePickerTheme.hourMinuteTextColor ??
|
|
MaterialStateColor.resolveWith((states) {
|
|
return states.contains(MaterialState.selected)
|
|
? themeData.accentColor
|
|
: themeData.colorScheme.onSurface;
|
|
});
|
|
final backgroundColor = timePickerTheme.hourMinuteColor ??
|
|
MaterialStateColor.resolveWith((states) {
|
|
return states.contains(MaterialState.selected)
|
|
? themeData.accentColor.withOpacity(isDark ? 0.24 : 0.12)
|
|
: themeData.colorScheme.onSurface.withOpacity(0.12);
|
|
});
|
|
final style =
|
|
timePickerTheme.hourMinuteTextStyle ?? themeData.textTheme.headline2;
|
|
final shape = timePickerTheme.hourMinuteShape ?? _kDefaultShape;
|
|
|
|
final states = isSelected
|
|
? <MaterialState>{MaterialState.selected}
|
|
: <MaterialState>{};
|
|
return Container(
|
|
height: _kTimePickerHeaderControlHeight,
|
|
child: Material(
|
|
color: MaterialStateProperty.resolveAs(backgroundColor, states),
|
|
clipBehavior: Clip.antiAlias,
|
|
shape: shape,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
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 alwaysUse24HourFormat = MediaQuery.of(context).alwaysUse24HourFormat;
|
|
final localizations = MaterialLocalizations.of(context);
|
|
final formattedHour = localizations.formatHour(
|
|
fragmentContext.selectedTime,
|
|
alwaysUse24HourFormat: alwaysUse24HourFormat,
|
|
);
|
|
|
|
TimeOfDay hoursFromSelected(int hoursToAdd) {
|
|
if (fragmentContext.use24HourDials) {
|
|
final selectedHour = fragmentContext.selectedTime.hour;
|
|
return fragmentContext.selectedTime.replacing(
|
|
hour: (selectedHour + hoursToAdd) % TimeOfDay.hoursPerDay,
|
|
);
|
|
} else {
|
|
// Cycle 1 through 12 without changing day period.
|
|
final periodOffset = fragmentContext.selectedTime.periodOffset;
|
|
final hours = fragmentContext.selectedTime.hourOfPeriod;
|
|
return fragmentContext.selectedTime.replacing(
|
|
hour: periodOffset + (hours + hoursToAdd) % TimeOfDay.hoursPerPeriod,
|
|
);
|
|
}
|
|
}
|
|
|
|
final nextHour = hoursFromSelected(1);
|
|
final formattedNextHour = localizations.formatHour(
|
|
nextHour,
|
|
alwaysUse24HourFormat: alwaysUse24HourFormat,
|
|
);
|
|
final previousHour = hoursFromSelected(-1);
|
|
final formattedPreviousHour = localizations.formatHour(
|
|
previousHour,
|
|
alwaysUse24HourFormat: alwaysUse24HourFormat,
|
|
);
|
|
|
|
return Semantics(
|
|
hint: localizations.timePickerHourModeAnnouncement,
|
|
value: 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),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final timePickerTheme = TimePickerTheme.of(context);
|
|
final hourMinuteStyle =
|
|
timePickerTheme.hourMinuteTextStyle ?? theme.textTheme.headline2;
|
|
final 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 localizations = MaterialLocalizations.of(context);
|
|
final formattedMinute =
|
|
localizations.formatMinute(fragmentContext.selectedTime);
|
|
final nextMinute = fragmentContext.selectedTime.replacing(
|
|
minute:
|
|
(fragmentContext.selectedTime.minute + 1) % TimeOfDay.minutesPerHour,
|
|
);
|
|
final formattedNextMinute = localizations.formatMinute(nextMinute);
|
|
final previousMinute = fragmentContext.selectedTime.replacing(
|
|
minute:
|
|
(fragmentContext.selectedTime.minute - 1) % TimeOfDay.minutesPerHour,
|
|
);
|
|
final formattedPreviousMinute = localizations.formatMinute(previousMinute);
|
|
|
|
return Semantics(
|
|
excludeSemantics: true,
|
|
hint: localizations.timePickerMinuteModeAnnouncement,
|
|
value: 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),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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 newHour =
|
|
(selectedTime.hour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay;
|
|
final 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.of(context);
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final timePickerTheme = TimePickerTheme.of(context);
|
|
final isDark = colorScheme.brightness == Brightness.dark;
|
|
final textColor = timePickerTheme.dayPeriodTextColor ??
|
|
MaterialStateColor.resolveWith((states) {
|
|
return states.contains(MaterialState.selected)
|
|
? colorScheme.primary
|
|
: colorScheme.onSurface.withOpacity(0.60);
|
|
});
|
|
final backgroundColor = timePickerTheme.dayPeriodColor ??
|
|
MaterialStateColor.resolveWith((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 amSelected = selectedTime.period == DayPeriod.am;
|
|
final amStates = amSelected
|
|
? <MaterialState>{MaterialState.selected}
|
|
: <MaterialState>{};
|
|
final pmSelected = !amSelected;
|
|
final pmStates = pmSelected
|
|
? <MaterialState>{MaterialState.selected}
|
|
: <MaterialState>{};
|
|
final textStyle = timePickerTheme.dayPeriodTextStyle ??
|
|
Theme.of(context).textTheme.subtitle1;
|
|
final amStyle = textStyle.copyWith(
|
|
color: MaterialStateProperty.resolveAs(textColor, amStates),
|
|
);
|
|
final pmStyle = textStyle.copyWith(
|
|
color: MaterialStateProperty.resolveAs(textColor, pmStates),
|
|
);
|
|
var shape = timePickerTheme.dayPeriodShape ??
|
|
const RoundedRectangleBorder(borderRadius: _kDefaultBorderRadius);
|
|
final borderSide = timePickerTheme.dayPeriodBorderSide ??
|
|
BorderSide(
|
|
color: Color.alphaBlend(
|
|
colorScheme.onBackground.withOpacity(0.38), colorScheme.surface),
|
|
);
|
|
// Apply the custom borderSide.
|
|
shape = shape.copyWith(
|
|
side: borderSide,
|
|
);
|
|
|
|
final 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(
|
|
selected: amSelected,
|
|
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(
|
|
selected: pmSelected,
|
|
child: Center(
|
|
child: Text(
|
|
materialLocalizations.postMeridiemAbbreviation,
|
|
style: pmStyle,
|
|
textScaleFactor: buttonTextScaleFactor,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
Widget result;
|
|
switch (orientation) {
|
|
case Orientation.portrait:
|
|
const width = 52.0;
|
|
result = _DayPeriodInputPadding(
|
|
minSize: const Size(width, kMinInteractiveDimension * 2),
|
|
orientation: orientation,
|
|
child: Container(
|
|
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: Container(
|
|
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,
|
|
Widget child,
|
|
this.minSize,
|
|
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;
|
|
}
|
|
|
|
@override
|
|
void performLayout() {
|
|
if (child != null) {
|
|
child.layout(constraints, parentUsesSize: true);
|
|
final width = math.max(child.size.width, minSize.width);
|
|
final height = math.max(child.size.height, minSize.height);
|
|
size = constraints.constrain(Size(width, height));
|
|
final childParentData = child.parentData as BoxParentData;
|
|
childParentData.offset =
|
|
Alignment.center.alongOffset(size - child.size as Offset);
|
|
} else {
|
|
size = Size.zero;
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool hitTest(BoxHitTestResult result, {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;
|
|
}
|
|
|
|
var 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: (result, 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 radius = size.shortestSide / 2.0;
|
|
final center = Offset(size.width / 2.0, size.height / 2.0);
|
|
final centerPoint = center;
|
|
canvas.drawCircle(centerPoint, radius, Paint()..color = backgroundColor);
|
|
|
|
final 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 labelThetaIncrement = -_kTwoPi / labels.length;
|
|
var labelTheta = math.pi / 2.0;
|
|
|
|
for (final label in labels) {
|
|
final labelPainter = label.painter;
|
|
final labelOffset =
|
|
Offset(-labelPainter.width / 2.0, -labelPainter.height / 2.0);
|
|
labelPainter.paint(canvas, getOffsetForTheta(labelTheta) + labelOffset);
|
|
labelTheta += labelThetaIncrement;
|
|
}
|
|
}
|
|
|
|
paintLabels(primaryLabels);
|
|
|
|
final selectorPaint = Paint()..color = accentColor;
|
|
final focusedPoint = getOffsetForTheta(theta);
|
|
const 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 labelThetaIncrement = -_kTwoPi / primaryLabels.length;
|
|
if (theta % labelThetaIncrement > 0.1 &&
|
|
theta % labelThetaIncrement < 0.45) {
|
|
canvas.drawCircle(focusedPoint, 2.0, selectorPaint..color = dotColor);
|
|
}
|
|
|
|
final 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,
|
|
}) : assert(selectedTime != null),
|
|
assert(mode != null),
|
|
assert(use24HourDials != null);
|
|
|
|
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: Curves.easeInSine))
|
|
.drive(_thetaTween)
|
|
..addListener(() => setState(() {/* _theta.value has changed */}));
|
|
}
|
|
|
|
ThemeData themeData;
|
|
MaterialLocalizations localizations;
|
|
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();
|
|
}
|
|
|
|
Tween<double> _thetaTween;
|
|
Animation<double> _theta;
|
|
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 currentTheta = _theta.value;
|
|
var 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 hoursFactor = widget.use24HourDials
|
|
? TimeOfDay.hoursPerDay
|
|
: TimeOfDay.hoursPerPeriod;
|
|
final 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 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 {
|
|
var 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 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 = _position - _center;
|
|
var 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 box = context.findRenderObject() as RenderBox;
|
|
_position = box.globalToLocal(details.globalPosition);
|
|
_center = box.size.center(Offset.zero);
|
|
_updateThetaForPan();
|
|
_notifyOnChangedIfNeeded();
|
|
}
|
|
|
|
void _handlePanUpdate(DragUpdateDetails details) {
|
|
_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) {
|
|
if (widget.onHourSelected != null) {
|
|
widget.onHourSelected();
|
|
}
|
|
}
|
|
}
|
|
|
|
void _handleTapUp(TapUpDetails details) {
|
|
final box = context.findRenderObject() as RenderBox;
|
|
_position = box.globalToLocal(details.globalPosition);
|
|
_center = box.size.center(Offset.zero);
|
|
_updateThetaForPan(roundMinutes: true);
|
|
final 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));
|
|
}
|
|
if (widget.onHourSelected != null) {
|
|
widget.onHourSelected();
|
|
}
|
|
} 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));
|
|
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 angle = _getThetaForTime(time);
|
|
_thetaTween
|
|
..begin = angle
|
|
..end = angle;
|
|
_notifyOnChangedIfNeeded();
|
|
}
|
|
|
|
void _selectMinute(int minute) {
|
|
_announceToAccessibility(context, localizations.formatDecimal(minute));
|
|
final time = TimeOfDay(
|
|
hour: widget.selectedTime.hour,
|
|
minute: minute,
|
|
);
|
|
final 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 style = textTheme.subtitle1.copyWith(color: color);
|
|
final 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 _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 theme = Theme.of(context);
|
|
final pickerTheme = TimePickerTheme.of(context);
|
|
final backgroundColor = pickerTheme.dialBackgroundColor ??
|
|
themeData.colorScheme.onBackground.withOpacity(0.12);
|
|
final accentColor = pickerTheme.dialHandColor ?? themeData.accentColor;
|
|
final primaryLabelColor = MaterialStateProperty.resolveAs(
|
|
pickerTheme.dialTextColor, <MaterialState>{});
|
|
final secondaryLabelColor = MaterialStateProperty.resolveAs(
|
|
pickerTheme.dialTextColor, <MaterialState>{MaterialState.selected});
|
|
List<_TappableLabel> primaryLabels;
|
|
List<_TappableLabel> secondaryLabels;
|
|
int selectedDialValue;
|
|
switch (widget.mode) {
|
|
case _TimePickerMode.hour:
|
|
if (widget.use24HourDials) {
|
|
selectedDialValue = widget.selectedTime.hour;
|
|
primaryLabels = _build24HourRing(theme.textTheme, primaryLabelColor);
|
|
secondaryLabels =
|
|
_build24HourRing(theme.accentTextTheme, secondaryLabelColor);
|
|
} else {
|
|
selectedDialValue = widget.selectedTime.hourOfPeriod;
|
|
primaryLabels = _build12HourRing(theme.textTheme, primaryLabelColor);
|
|
secondaryLabels =
|
|
_build12HourRing(theme.accentTextTheme, secondaryLabelColor);
|
|
}
|
|
break;
|
|
case _TimePickerMode.minute:
|
|
selectedDialValue = widget.selectedTime.minute;
|
|
primaryLabels = _buildMinutes(theme.textTheme, primaryLabelColor);
|
|
secondaryLabels =
|
|
_buildMinutes(theme.accentTextTheme, 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.initialSelectedTime,
|
|
@required this.helpText,
|
|
@required this.onChanged,
|
|
}) : assert(initialSelectedTime != null),
|
|
assert(onChanged != null),
|
|
super(key: key);
|
|
|
|
/// The time initially selected when the dialog is shown.
|
|
final TimeOfDay initialSelectedTime;
|
|
|
|
/// Optionally provide your own help text to the time picker.
|
|
final String helpText;
|
|
|
|
final ValueChanged<TimeOfDay> onChanged;
|
|
|
|
@override
|
|
_TimePickerInputState createState() => _TimePickerInputState();
|
|
}
|
|
|
|
class _TimePickerInputState extends State<_TimePickerInput> {
|
|
TimeOfDay _selectedTime;
|
|
bool hourHasError = false;
|
|
bool minuteHasError = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_selectedTime = widget.initialSelectedTime;
|
|
}
|
|
|
|
int _parseHour(String value) {
|
|
if (value == null) {
|
|
return null;
|
|
}
|
|
|
|
var 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.period == DayPeriod.pm && newHour != 12) ||
|
|
(_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 newMinute = int.tryParse(value);
|
|
if (newMinute == null) {
|
|
return null;
|
|
}
|
|
|
|
if (newMinute >= 0 && newMinute < 60) {
|
|
return newMinute;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
void _handleHourSavedSubmitted(String value) {
|
|
final newHour = _parseHour(value);
|
|
if (newHour != null) {
|
|
_selectedTime = TimeOfDay(hour: newHour, minute: _selectedTime.minute);
|
|
widget.onChanged(_selectedTime);
|
|
}
|
|
}
|
|
|
|
void _handleHourChanged(String value) {
|
|
final 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 newMinute = _parseMinute(value);
|
|
if (newMinute != null) {
|
|
_selectedTime =
|
|
TimeOfDay(hour: _selectedTime.hour, minute: int.parse(value));
|
|
widget.onChanged(_selectedTime);
|
|
}
|
|
}
|
|
|
|
void _handleDayPeriodChanged(TimeOfDay value) {
|
|
_selectedTime = value;
|
|
widget.onChanged(_selectedTime);
|
|
}
|
|
|
|
String _validateHour(String value) {
|
|
final newHour = _parseHour(value);
|
|
setState(() {
|
|
hourHasError = 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 newMinute = _parseMinute(value);
|
|
setState(() {
|
|
minuteHasError = 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 media = MediaQuery.of(context);
|
|
final timeOfDayFormat = MaterialLocalizations.of(context)
|
|
.timeOfDayFormat(alwaysUse24HourFormat: media.alwaysUse24HourFormat);
|
|
final use24HourDials = hourFormat(of: timeOfDayFormat) != HourFormat.h;
|
|
final theme = Theme.of(context);
|
|
final 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: _selectedTime,
|
|
orientation: Orientation.portrait,
|
|
onChanged: _handleDayPeriodChanged,
|
|
),
|
|
const SizedBox(width: 12.0),
|
|
],
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
const SizedBox(height: 8.0),
|
|
_HourMinuteTextField(
|
|
selectedTime: _selectedTime,
|
|
isHour: true,
|
|
style: hourMinuteStyle,
|
|
validator: _validateHour,
|
|
onSavedSubmitted: _handleHourSavedSubmitted,
|
|
onChanged: _handleHourChanged,
|
|
),
|
|
const SizedBox(height: 8.0),
|
|
if (!hourHasError && !minuteHasError)
|
|
ExcludeSemantics(
|
|
child: Text(
|
|
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),
|
|
_HourMinuteTextField(
|
|
selectedTime: _selectedTime,
|
|
isHour: false,
|
|
style: hourMinuteStyle,
|
|
validator: _validateMinute,
|
|
onSavedSubmitted: _handleMinuteSavedSubmitted,
|
|
),
|
|
const SizedBox(height: 8.0),
|
|
if (!hourHasError && !minuteHasError)
|
|
ExcludeSemantics(
|
|
child: Text(
|
|
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: _selectedTime,
|
|
orientation: Orientation.portrait,
|
|
onChanged: _handleDayPeriodChanged,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
if (hourHasError || minuteHasError)
|
|
Text(
|
|
MaterialLocalizations.of(context).invalidTimeLabel,
|
|
style: theme.textTheme.bodyText2
|
|
.copyWith(color: theme.colorScheme.error),
|
|
)
|
|
else
|
|
const SizedBox(height: 2.0),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HourMinuteTextField extends StatefulWidget {
|
|
const _HourMinuteTextField({
|
|
Key key,
|
|
@required this.selectedTime,
|
|
@required this.isHour,
|
|
@required this.style,
|
|
@required this.validator,
|
|
@required this.onSavedSubmitted,
|
|
this.onChanged,
|
|
}) : super(key: key);
|
|
|
|
final TimeOfDay selectedTime;
|
|
final bool isHour;
|
|
final TextStyle style;
|
|
final FormFieldValidator<String> validator;
|
|
final ValueChanged<String> onSavedSubmitted;
|
|
final ValueChanged<String> onChanged;
|
|
|
|
@override
|
|
_HourMinuteTextFieldState createState() => _HourMinuteTextFieldState();
|
|
}
|
|
|
|
class _HourMinuteTextFieldState extends State<_HourMinuteTextField> {
|
|
TextEditingController controller;
|
|
FocusNode focusNode;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
focusNode = FocusNode()
|
|
..addListener(() {
|
|
setState(() {}); // Rebuild.
|
|
});
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
controller ??= TextEditingController(text: _formattedValue);
|
|
}
|
|
|
|
String get _formattedValue {
|
|
final alwaysUse24HourFormat = MediaQuery.of(context).alwaysUse24HourFormat;
|
|
final localizations = MaterialLocalizations.of(context);
|
|
return !widget.isHour
|
|
? localizations.formatMinute(widget.selectedTime)
|
|
: localizations.formatHour(
|
|
widget.selectedTime,
|
|
alwaysUse24HourFormat: alwaysUse24HourFormat,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final timePickerTheme = TimePickerTheme.of(context);
|
|
final colorScheme = theme.colorScheme;
|
|
|
|
final inputDecorationTheme = timePickerTheme.inputDecorationTheme;
|
|
InputDecoration inputDecoration;
|
|
if (inputDecorationTheme != null) {
|
|
inputDecoration =
|
|
const InputDecoration().applyDefaults(inputDecorationTheme);
|
|
} else {
|
|
final unfocusedFillColor = timePickerTheme.hourMinuteColor ??
|
|
colorScheme.onSurface.withOpacity(0.12);
|
|
inputDecoration = InputDecoration(
|
|
contentPadding: EdgeInsets.zero,
|
|
filled: true,
|
|
fillColor: focusNode.hasFocus ? Colors.transparent : unfocusedFillColor,
|
|
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.
|
|
);
|
|
}
|
|
inputDecoration = inputDecoration.copyWith(
|
|
// Remove the hint text when focused because the centered cursor appears
|
|
// odd above the hint text.
|
|
hintText: focusNode.hasFocus ? null : _formattedValue,
|
|
);
|
|
|
|
return SizedBox(
|
|
height: _kTimePickerHeaderControlHeight,
|
|
child: MediaQuery(
|
|
data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0),
|
|
child: TextFormField(
|
|
expands: true,
|
|
maxLines: null,
|
|
focusNode: focusNode,
|
|
textAlign: TextAlign.center,
|
|
keyboardType: TextInputType.number,
|
|
style: widget.style.copyWith(color: colorScheme.onSurface),
|
|
controller: controller,
|
|
decoration: inputDecoration,
|
|
validator: widget.validator,
|
|
onEditingComplete: () => widget.onSavedSubmitted(controller.text),
|
|
onSaved: widget.onSavedSubmitted,
|
|
onFieldSubmitted: widget.onSavedSubmitted,
|
|
onChanged: widget.onChanged,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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 _TimePickerDialog extends StatefulWidget {
|
|
/// Creates a material time picker.
|
|
///
|
|
/// [initialTime] must not be null.
|
|
const _TimePickerDialog({
|
|
Key key,
|
|
@required this.initialTime,
|
|
@required this.cancelText,
|
|
@required this.confirmText,
|
|
@required this.helpText,
|
|
this.initialEntryMode = TimePickerEntryMode.dial,
|
|
}) : assert(initialTime != null),
|
|
super(key: key);
|
|
|
|
/// 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;
|
|
|
|
/// 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;
|
|
|
|
@override
|
|
_TimePickerDialogState createState() => _TimePickerDialogState();
|
|
}
|
|
|
|
class _TimePickerDialogState extends State<_TimePickerDialog> {
|
|
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_selectedTime = widget.initialTime;
|
|
_entryMode = widget.initialEntryMode;
|
|
_autoValidate = false;
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
localizations = MaterialLocalizations.of(context);
|
|
_announceInitialTimeOnce();
|
|
_announceModeOnce();
|
|
}
|
|
|
|
TimePickerEntryMode _entryMode;
|
|
_TimePickerMode _mode = _TimePickerMode.hour;
|
|
_TimePickerMode _lastModeAnnounced;
|
|
bool _autoValidate;
|
|
|
|
TimeOfDay get selectedTime => _selectedTime;
|
|
TimeOfDay _selectedTime;
|
|
|
|
Timer _vibrateTimer;
|
|
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 = mode;
|
|
_announceModeOnce();
|
|
});
|
|
}
|
|
|
|
void _handleEntryModeToggle() {
|
|
setState(() {
|
|
switch (_entryMode) {
|
|
case TimePickerEntryMode.dial:
|
|
_autoValidate = false;
|
|
_entryMode = TimePickerEntryMode.input;
|
|
break;
|
|
case TimePickerEntryMode.input:
|
|
_formKey.currentState.save();
|
|
_entryMode = TimePickerEntryMode.dial;
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
|
|
void _announceModeOnce() {
|
|
if (_lastModeAnnounced == _mode) {
|
|
// Already announced it.
|
|
return;
|
|
}
|
|
|
|
switch (_mode) {
|
|
case _TimePickerMode.hour:
|
|
_announceToAccessibility(
|
|
context, localizations.timePickerHourModeAnnouncement);
|
|
break;
|
|
case _TimePickerMode.minute:
|
|
_announceToAccessibility(
|
|
context, localizations.timePickerMinuteModeAnnouncement);
|
|
break;
|
|
}
|
|
_lastModeAnnounced = _mode;
|
|
}
|
|
|
|
bool _announcedInitialTime = false;
|
|
|
|
void _announceInitialTimeOnce() {
|
|
if (_announcedInitialTime) return;
|
|
|
|
final media = MediaQuery.of(context);
|
|
final localizations = MaterialLocalizations.of(context);
|
|
_announceToAccessibility(
|
|
context,
|
|
localizations.formatTimeOfDay(widget.initialTime,
|
|
alwaysUse24HourFormat: media.alwaysUse24HourFormat),
|
|
);
|
|
_announcedInitialTime = true;
|
|
}
|
|
|
|
void _handleTimeChanged(TimeOfDay value) {
|
|
_vibrate();
|
|
setState(() {
|
|
_selectedTime = value;
|
|
});
|
|
}
|
|
|
|
void _handleHourSelected() {
|
|
setState(() {
|
|
_mode = _TimePickerMode.minute;
|
|
});
|
|
}
|
|
|
|
void _handleCancel() {
|
|
Navigator.pop(context);
|
|
}
|
|
|
|
void _handleOk() {
|
|
if (_entryMode == TimePickerEntryMode.input) {
|
|
final form = _formKey.currentState;
|
|
if (!form.validate()) {
|
|
setState(() {
|
|
_autoValidate = true;
|
|
});
|
|
return;
|
|
}
|
|
form.save();
|
|
}
|
|
Navigator.pop(context, _selectedTime);
|
|
}
|
|
|
|
Size _dialogSize(BuildContext context) {
|
|
final orientation = MediaQuery.of(context).orientation;
|
|
final 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 textScaleFactor =
|
|
math.min(MediaQuery.of(context).textScaleFactor, 1.1);
|
|
|
|
double timePickerWidth;
|
|
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);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
assert(debugCheckHasMediaQuery(context));
|
|
final media = MediaQuery.of(context);
|
|
final timeOfDayFormat = localizations.timeOfDayFormat(
|
|
alwaysUse24HourFormat: media.alwaysUse24HourFormat);
|
|
final use24HourDials = hourFormat(of: timeOfDayFormat) != HourFormat.h;
|
|
final theme = Theme.of(context);
|
|
final shape = TimePickerTheme.of(context).shape ?? _kDefaultShape;
|
|
final orientation = media.orientation;
|
|
|
|
final Widget actions = 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(
|
|
// TODO(rami-a): Move away from ButtonBar to avoid https://github.com/flutter/flutter/issues/53378.
|
|
child: ButtonBar(
|
|
layoutBehavior: ButtonBarLayoutBehavior.constrained,
|
|
children: <Widget>[
|
|
FlatButton(
|
|
onPressed: _handleCancel,
|
|
child: Text(
|
|
widget.cancelText ?? localizations.cancelButtonLabel,
|
|
style: TextStyle(color: Colors.grey[600]),
|
|
),
|
|
),
|
|
FlatButton(
|
|
onPressed: _handleOk,
|
|
child: Text(
|
|
widget.confirmText ?? localizations.okButtonLabel,
|
|
style: TextStyle(color: Theme.of(context).accentColor),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
Widget picker;
|
|
switch (_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,
|
|
use24HourDials: use24HourDials,
|
|
selectedTime: _selectedTime,
|
|
onChanged: _handleTimeChanged,
|
|
onHourSelected: _handleHourSelected,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final Widget header = _TimePickerHeader(
|
|
selectedTime: _selectedTime,
|
|
mode: _mode,
|
|
orientation: orientation,
|
|
onModeChanged: _handleModeChanged,
|
|
onChanged: _handleTimeChanged,
|
|
use24HourDials: use24HourDials,
|
|
helpText: widget.helpText,
|
|
);
|
|
|
|
switch (orientation) {
|
|
case Orientation.portrait:
|
|
picker = Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: <Widget>[
|
|
header,
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
// Dial grows and shrinks with the available space.
|
|
Expanded(child: dial),
|
|
actions,
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
break;
|
|
case Orientation.landscape:
|
|
picker = Column(
|
|
children: <Widget>[
|
|
Expanded(
|
|
child: Row(
|
|
children: <Widget>[
|
|
header,
|
|
Expanded(child: dial),
|
|
],
|
|
),
|
|
),
|
|
actions,
|
|
],
|
|
);
|
|
break;
|
|
}
|
|
break;
|
|
case TimePickerEntryMode.input:
|
|
picker = Form(
|
|
key: _formKey,
|
|
autovalidate: _autoValidate,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
_TimePickerInput(
|
|
initialSelectedTime: _selectedTime,
|
|
helpText: widget.helpText,
|
|
onChanged: _handleTimeChanged,
|
|
),
|
|
actions,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
break;
|
|
}
|
|
|
|
final dialogSize = _dialogSize(context);
|
|
return Dialog(
|
|
shape: shape,
|
|
elevation: 2,
|
|
backgroundColor: TimePickerTheme.of(context).backgroundColor ??
|
|
theme.colorScheme.surface,
|
|
insetPadding: EdgeInsets.symmetric(
|
|
horizontal: 16.0,
|
|
vertical: _entryMode == TimePickerEntryMode.input ? 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;
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
/// 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<TimeOfDay> selectedTime = showTimePicker(
|
|
/// 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 [entryMode] parameter can be used to
|
|
/// determine the initial time entry selection of the picker (either a clock
|
|
/// dial or text input).
|
|
///
|
|
/// Optional strings for the [helpText], [cancelText], and [confirmText] can be
|
|
/// provided to override the default values.
|
|
///
|
|
/// {@tool snippet}
|
|
/// Show a dialog with the text direction overridden to be [TextDirection.rtl].
|
|
///
|
|
/// ```dart
|
|
/// Future<TimeOfDay> selectedTimeRTL = showTimePicker(
|
|
/// 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<TimeOfDay> selectedTime24Hour = showTimePicker(
|
|
/// context: context,
|
|
/// initialTime: 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.
|
|
Future<TimeOfDay> showCustomTimePicker({
|
|
@required BuildContext context,
|
|
@required TimeOfDay initialTime,
|
|
TransitionBuilder builder,
|
|
bool useRootNavigator = true,
|
|
TimePickerEntryMode initialEntryMode = TimePickerEntryMode.dial,
|
|
String cancelText,
|
|
String confirmText,
|
|
String helpText,
|
|
RouteSettings routeSettings,
|
|
}) async {
|
|
assert(context != null);
|
|
assert(initialTime != null);
|
|
assert(useRootNavigator != null);
|
|
assert(initialEntryMode != null);
|
|
assert(debugCheckHasMaterialLocalizations(context));
|
|
|
|
final Widget dialog = _TimePickerDialog(
|
|
initialTime: initialTime,
|
|
initialEntryMode: initialEntryMode,
|
|
cancelText: cancelText,
|
|
confirmText: confirmText,
|
|
helpText: helpText,
|
|
);
|
|
return await showGeneralDialog<TimeOfDay>(
|
|
context: context,
|
|
useRootNavigator: useRootNavigator,
|
|
routeSettings: routeSettings,
|
|
barrierDismissible: true,
|
|
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
|
|
barrierColor: Colors.black54,
|
|
transitionDuration: const Duration(milliseconds: 200),
|
|
pageBuilder: (context, animaiton, secondaryAnimation) =>
|
|
AnnotatedRegion<SystemUiOverlayStyle>(
|
|
value: SystemUiOverlayStyle(
|
|
statusBarIconBrightness: Brightness.light,
|
|
systemNavigationBarColor:
|
|
Theme.of(context).brightness == Brightness.light
|
|
? Color.fromRGBO(113, 113, 113, 1)
|
|
: Color.fromRGBO(15, 15, 15, 1),
|
|
),
|
|
child: Builder(builder: (context) {
|
|
return builder == null ? dialog : builder(context, dialog);
|
|
}),
|
|
//routeSettings: routeSettings,
|
|
),
|
|
);
|
|
}
|
|
|
|
void _announceToAccessibility(BuildContext context, String message) {
|
|
SemanticsService.announce(message, Directionality.of(context));
|
|
}
|