time_picker.dart 85 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5
import 'dart:async';
6 7
import 'dart:math' as math;

8
import 'package:flutter/rendering.dart';
9
import 'package:flutter/services.dart';
10 11
import 'package:flutter/widgets.dart';

12
import 'color_scheme.dart';
13
import 'colors.dart';
14 15
import 'constants.dart';
import 'curves.dart';
16
import 'debug.dart';
17
import 'dialog.dart';
18
import 'feedback.dart';
19 20
import 'icon_button.dart';
import 'icons.dart';
21
import 'ink_well.dart';
22 23
import 'input_border.dart';
import 'input_decorator.dart';
24
import 'material.dart';
25
import 'material_localizations.dart';
26
import 'material_state.dart';
27
import 'text_button.dart';
28
import 'text_form_field.dart';
29
import 'text_theme.dart';
30
import 'theme.dart';
31
import 'theme_data.dart';
32
import 'time.dart';
33
import 'time_picker_theme.dart';
34

35
// Examples can assume:
36
// late BuildContext context;
37

38
const Duration _kDialogSizeAnimationDuration = Duration(milliseconds: 200);
39
const Duration _kDialAnimateDuration = Duration(milliseconds: 200);
40
const double _kTwoPi = 2 * math.pi;
41
const Duration _kVibrateCommitDelay = Duration(milliseconds: 100);
Adam Barth's avatar
Adam Barth committed
42

43 44
enum _TimePickerMode { hour, minute }

45 46
const double _kTimePickerHeaderLandscapeWidth = 264.0;
const double _kTimePickerHeaderControlHeight = 80.0;
47

48
const double _kTimePickerWidthPortrait = 328.0;
49
const double _kTimePickerWidthLandscape = 528.0;
50

51
const double _kTimePickerHeightInput = 226.0;
52 53 54 55 56
const double _kTimePickerHeightPortrait = 496.0;
const double _kTimePickerHeightLandscape = 316.0;

const double _kTimePickerHeightPortraitCollapsed = 484.0;
const double _kTimePickerHeightLandscapeCollapsed = 304.0;
57

58 59
const BorderRadius _kDefaultBorderRadius = BorderRadius.all(Radius.circular(4.0));
const ShapeBorder _kDefaultShape = RoundedRectangleBorder(borderRadius: _kDefaultBorderRadius);
Yegor's avatar
Yegor committed
60

61 62 63 64 65 66 67
/// 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 {
68 69 70
  /// User picks time from a clock dial.
  ///
  /// Can switch to [input] by activating a mode button in the dialog.
71 72
  dial,

73 74 75
  /// User can input the time by typing it into text fields.
  ///
  /// Can switch to [dial] by activating a mode button in the dialog.
76
  input,
77 78 79 80 81 82 83 84 85 86

  /// User can only pick time from a clock dial.
  ///
  /// There is no user interface to switch to another mode.
  dialOnly,

  /// User can only input the time by typing it into text fields.
  ///
  /// There is no user interface to switch to another mode.
  inputOnly
Yegor's avatar
Yegor committed
87 88 89 90 91 92
}

/// Provides properties for rendering time picker header fragments.
@immutable
class _TimePickerFragmentContext {
  const _TimePickerFragmentContext({
93 94 95 96 97 98 99
    required this.selectedTime,
    required this.mode,
    required this.onTimeChange,
    required this.onModeChange,
    required this.onHourDoubleTapped,
    required this.onMinuteDoubleTapped,
    required this.use24HourDials,
100
  }) : assert(selectedTime != null),
Yegor's avatar
Yegor committed
101 102
       assert(mode != null),
       assert(onTimeChange != null),
103
       assert(onModeChange != null),
104
       assert(use24HourDials != null);
Yegor's avatar
Yegor committed
105 106 107 108 109

  final TimeOfDay selectedTime;
  final _TimePickerMode mode;
  final ValueChanged<TimeOfDay> onTimeChange;
  final ValueChanged<_TimePickerMode> onModeChange;
110 111
  final GestureTapCallback onHourDoubleTapped;
  final GestureTapCallback onMinuteDoubleTapped;
112
  final bool use24HourDials;
Yegor's avatar
Yegor committed
113 114
}

115 116
class _TimePickerHeader extends StatelessWidget {
  const _TimePickerHeader({
117 118 119 120 121 122 123 124 125
    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,
126 127 128 129
  }) : assert(selectedTime != null),
       assert(mode != null),
       assert(orientation != null),
       assert(use24HourDials != null);
Yegor's avatar
Yegor committed
130

131 132
  final TimeOfDay selectedTime;
  final _TimePickerMode mode;
133
  final Orientation orientation;
134 135
  final ValueChanged<_TimePickerMode> onModeChanged;
  final ValueChanged<TimeOfDay> onChanged;
136 137
  final GestureTapCallback onHourDoubleTapped;
  final GestureTapCallback onMinuteDoubleTapped;
138
  final bool use24HourDials;
139
  final String? helpText;
Yegor's avatar
Yegor committed
140

141
  void _handleChangeMode(_TimePickerMode value) {
142
    if (value != mode) {
143
      onModeChanged(value);
144
    }
Yegor's avatar
Yegor committed
145 146 147 148
  }

  @override
  Widget build(BuildContext context) {
149
    assert(debugCheckHasMediaQuery(context));
150
    final ThemeData themeData = Theme.of(context);
151
    final TimeOfDayFormat timeOfDayFormat = MaterialLocalizations.of(context).timeOfDayFormat(
152
      alwaysUse24HourFormat: MediaQuery.of(context).alwaysUse24HourFormat,
Yegor's avatar
Yegor committed
153
    );
154 155 156 157
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
    final String timePickerDialHelpText = themeData.useMaterial3
      ? localizations.timePickerDialHelpText
      : localizations.timePickerDialHelpText.toUpperCase();
158 159 160 161 162 163

    final _TimePickerFragmentContext fragmentContext = _TimePickerFragmentContext(
      selectedTime: selectedTime,
      mode: mode,
      onTimeChange: onChanged,
      onModeChange: _handleChangeMode,
164 165
      onHourDoubleTapped: onHourDoubleTapped,
      onMinuteDoubleTapped: onMinuteDoubleTapped,
166
      use24HourDials: use24HourDials,
Yegor's avatar
Yegor committed
167
    );
168

169
    final EdgeInsets padding;
170
    double? width;
171
    final Widget controls;
172

173 174 175 176 177 178 179
    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),
180
            SizedBox(
181 182 183 184 185 186 187 188 189 190 191
              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),
                  ],
192 193 194 195 196 197 198 199 200 201 202
                  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)),
                      ],
                    ),
                  ),
203 204 205 206 207 208 209
                  if (!use24HourDials && timeOfDayFormat != TimeOfDayFormat.a_space_h_colon_mm) ...<Widget>[
                    const SizedBox(width: 12.0),
                    _DayPeriodControl(
                      selectedTime: selectedTime,
                      orientation: orientation,
                      onChanged: onChanged,
                    ),
210
                  ],
211
                ],
212 213
              ),
            ),
214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229
          ],
        );
        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,
                ),
230
              SizedBox(
231 232
                height: kMinInteractiveDimension * 2,
                child: Row(
233 234
                  // Hour/minutes should not change positions in RTL locales.
                  textDirection: TextDirection.ltr,
235 236 237 238 239 240 241 242 243 244 245 246 247 248
                  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,
                ),
            ],
249
          ),
250 251 252 253 254 255 256 257 258 259 260 261
        );
        break;
    }

    return Container(
      width: width,
      padding: padding,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          const SizedBox(height: 16.0),
          Text(
262
            helpText ?? timePickerDialHelpText,
263
            style: TimePickerTheme.of(context).helpTextStyle ?? themeData.textTheme.labelSmall,
264 265 266
          ),
          controls,
        ],
267 268
      ),
    );
269 270 271 272 273
  }
}

class _HourMinuteControl extends StatelessWidget {
  const _HourMinuteControl({
274 275 276 277
    required this.text,
    required this.onTap,
    required this.onDoubleTap,
    required this.isSelected,
278 279 280 281 282 283
  }) : assert(text != null),
       assert(onTap != null),
       assert(isSelected != null);

  final String text;
  final GestureTapCallback onTap;
284
  final GestureTapCallback onDoubleTap;
285
  final bool isSelected;
286

287 288
  @override
  Widget build(BuildContext context) {
289
    final ThemeData themeData = Theme.of(context);
290 291 292 293 294 295 296 297 298 299 300 301 302 303
    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);
        });
304
    final TextStyle style = timePickerTheme.hourMinuteTextStyle ?? themeData.textTheme.displayMedium!;
305 306 307
    final ShapeBorder shape = timePickerTheme.hourMinuteShape ?? _kDefaultShape;

    final Set<MaterialState> states = isSelected ? <MaterialState>{MaterialState.selected} : <MaterialState>{};
308
    return SizedBox(
309
      height: _kTimePickerHeaderControlHeight,
310
      child: Material(
311 312 313
        color: MaterialStateProperty.resolveAs(backgroundColor, states),
        clipBehavior: Clip.antiAlias,
        shape: shape,
314
        child: InkWell(
315
          onTap: onTap,
316
          onDoubleTap: isSelected ? onDoubleTap : null,
317 318 319 320 321
          child: Center(
            child: Text(
              text,
              style: style.copyWith(color: MaterialStateProperty.resolveAs(textColor, states)),
              textScaleFactor: 1.0,
322
            ),
323 324
          ),
        ),
325
      ),
Yegor's avatar
Yegor committed
326 327 328 329 330 331 332 333
    );
  }
}
/// Displays the hour fragment.
///
/// When tapped changes time picker dial mode to [_TimePickerMode.hour].
class _HourControl extends StatelessWidget {
  const _HourControl({
334
    required this.fragmentContext,
Yegor's avatar
Yegor committed
335 336 337 338 339 340
  });

  final _TimePickerFragmentContext fragmentContext;

  @override
  Widget build(BuildContext context) {
341
    assert(debugCheckHasMediaQuery(context));
342
    final bool alwaysUse24HourFormat = MediaQuery.of(context).alwaysUse24HourFormat;
343
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
344 345
    final String formattedHour = localizations.formatHour(
      fragmentContext.selectedTime,
346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373
      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,
374
    );
Yegor's avatar
Yegor committed
375

376
    return Semantics(
377
      value: '${localizations.timePickerHourModeAnnouncement} $formattedHour',
378 379 380 381 382 383 384 385 386
      excludeSemantics: true,
      increasedValue: formattedNextHour,
      onIncrease: () {
        fragmentContext.onTimeChange(nextHour);
      },
      decreasedValue: formattedPreviousHour,
      onDecrease: () {
        fragmentContext.onTimeChange(previousHour);
      },
387 388 389
      child: _HourMinuteControl(
        isSelected: fragmentContext.mode == _TimePickerMode.hour,
        text: formattedHour,
390
        onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.hour), context)!,
391
        onDoubleTap: fragmentContext.onHourDoubleTapped,
392
      ),
Yegor's avatar
Yegor committed
393 394 395 396 397 398 399
    );
  }
}

/// A passive fragment showing a string value.
class _StringFragment extends StatelessWidget {
  const _StringFragment({
400
    required this.timeOfDayFormat,
Yegor's avatar
Yegor committed
401 402
  });

403 404 405 406 407 408 409 410 411 412 413 414 415 416 417
  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';
    }
  }
Yegor's avatar
Yegor committed
418 419 420

  @override
  Widget build(BuildContext context) {
421
    final ThemeData theme = Theme.of(context);
422
    final TimePickerThemeData timePickerTheme = TimePickerTheme.of(context);
423
    final TextStyle hourMinuteStyle = timePickerTheme.hourMinuteTextStyle ?? theme.textTheme.displayMedium!;
424 425
    final Color textColor = timePickerTheme.hourMinuteTextColor ?? theme.colorScheme.onSurface;

426
    return ExcludeSemantics(
427 428 429 430 431 432 433 434 435 436
      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,
          ),
        ),
      ),
437
    );
Yegor's avatar
Yegor committed
438 439 440 441 442 443 444 445
  }
}

/// Displays the minute fragment.
///
/// When tapped changes time picker dial mode to [_TimePickerMode.minute].
class _MinuteControl extends StatelessWidget {
  const _MinuteControl({
446
    required this.fragmentContext,
Yegor's avatar
Yegor committed
447 448 449 450 451 452
  });

  final _TimePickerFragmentContext fragmentContext;

  @override
  Widget build(BuildContext context) {
453
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
454 455 456 457 458 459 460 461 462
    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);
Yegor's avatar
Yegor committed
463

464 465
    return Semantics(
      excludeSemantics: true,
466
      value: '${localizations.timePickerMinuteModeAnnouncement} $formattedMinute',
467 468 469 470 471 472 473 474
      increasedValue: formattedNextMinute,
      onIncrease: () {
        fragmentContext.onTimeChange(nextMinute);
      },
      decreasedValue: formattedPreviousMinute,
      onDecrease: () {
        fragmentContext.onTimeChange(previousMinute);
      },
475 476 477
      child: _HourMinuteControl(
        isSelected: fragmentContext.mode == _TimePickerMode.minute,
        text: formattedMinute,
478
        onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.minute), context)!,
479
        onDoubleTap: fragmentContext.onMinuteDoubleTapped,
480
      ),
Yegor's avatar
Yegor committed
481 482 483 484 485
    );
  }
}


486 487 488 489
/// Displays the am/pm fragment and provides controls for switching between am
/// and pm.
class _DayPeriodControl extends StatelessWidget {
  const _DayPeriodControl({
490 491 492
    required this.selectedTime,
    required this.onChanged,
    required this.orientation,
493
  });
Yegor's avatar
Yegor committed
494

495 496 497
  final TimeOfDay selectedTime;
  final Orientation orientation;
  final ValueChanged<TimeOfDay> onChanged;
Yegor's avatar
Yegor committed
498

499 500 501 502
  void _togglePeriod() {
    final int newHour = (selectedTime.hour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay;
    final TimeOfDay newTime = selectedTime.replacing(hour: newHour);
    onChanged(newTime);
Yegor's avatar
Yegor committed
503 504
  }

505 506 507 508
  void _setAm(BuildContext context) {
    if (selectedTime.period == DayPeriod.am) {
      return;
    }
509
    switch (Theme.of(context).platform) {
510 511 512 513
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
      case TargetPlatform.linux:
      case TargetPlatform.windows:
514
        _announceToAccessibility(context, MaterialLocalizations.of(context).anteMeridiemAbbreviation);
Yegor's avatar
Yegor committed
515
        break;
516 517
      case TargetPlatform.iOS:
      case TargetPlatform.macOS:
Yegor's avatar
Yegor committed
518 519
        break;
    }
520 521 522 523 524 525
    _togglePeriod();
  }

  void _setPm(BuildContext context) {
    if (selectedTime.period == DayPeriod.pm) {
      return;
526
    }
527
    switch (Theme.of(context).platform) {
528 529 530 531
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
      case TargetPlatform.linux:
      case TargetPlatform.windows:
532
        _announceToAccessibility(context, MaterialLocalizations.of(context).postMeridiemAbbreviation);
533 534 535 536 537 538
        break;
      case TargetPlatform.iOS:
      case TargetPlatform.macOS:
        break;
    }
    _togglePeriod();
Yegor's avatar
Yegor committed
539 540
  }

541 542
  @override
  Widget build(BuildContext context) {
543
    final MaterialLocalizations materialLocalizations = MaterialLocalizations.of(context);
544
    final ColorScheme colorScheme = Theme.of(context).colorScheme;
545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566
    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>{};
567
    final TextStyle textStyle = timePickerTheme.dayPeriodTextStyle ?? Theme.of(context).textTheme.titleMedium!;
568 569 570 571 572 573 574 575 576 577 578 579 580 581 582
    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,
    );
583

584
    final double buttonTextScaleFactor = math.min(MediaQuery.of(context).textScaleFactor, 2.0);
585

586 587 588 589 590
    final Widget amButton = Material(
      color: MaterialStateProperty.resolveAs(backgroundColor, amStates),
      child: InkWell(
        onTap: Feedback.wrapForTap(() => _setAm(context), context),
        child: Semantics(
591 592
          checked: amSelected,
          inMutuallyExclusiveGroup: true,
593
          button: true,
594 595 596 597 598 599 600 601 602 603
          child: Center(
            child: Text(
              materialLocalizations.anteMeridiemAbbreviation,
              style: amStyle,
              textScaleFactor: buttonTextScaleFactor,
            ),
          ),
        ),
      ),
    );
604

605 606 607 608 609
    final Widget pmButton = Material(
      color: MaterialStateProperty.resolveAs(backgroundColor, pmStates),
      child: InkWell(
        onTap: Feedback.wrapForTap(() => _setPm(context), context),
        child: Semantics(
610 611
          checked: pmSelected,
          inMutuallyExclusiveGroup: true,
612
          button: true,
613 614 615 616 617 618 619 620 621 622
          child: Center(
            child: Text(
              materialLocalizations.postMeridiemAbbreviation,
              style: pmStyle,
              textScaleFactor: buttonTextScaleFactor,
            ),
          ),
        ),
      ),
    );
623

624
    final Widget result;
625 626
    switch (orientation) {
      case Orientation.portrait:
627 628 629 630
        const double width = 52.0;
        result = _DayPeriodInputPadding(
          minSize: const Size(width, kMinInteractiveDimension * 2),
          orientation: orientation,
631
          child: SizedBox(
632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652
            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),
                ],
              ),
            ),
          ),
        );
Yegor's avatar
Yegor committed
653 654
        break;
      case Orientation.landscape:
655 656 657
        result = _DayPeriodInputPadding(
          minSize: const Size(0.0, kMinInteractiveDimension),
          orientation: orientation,
658
          child: SizedBox(
659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678
            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),
                ],
              ),
            ),
          ),
        );
Yegor's avatar
Yegor committed
679 680
        break;
    }
681
    return result;
Yegor's avatar
Yegor committed
682
  }
683
}
684

685 686 687
/// A widget to pad the area around the [_DayPeriodControl]'s inner [Material].
class _DayPeriodInputPadding extends SingleChildRenderObjectWidget {
  const _DayPeriodInputPadding({
688
    required Widget super.child,
689 690
    required this.minSize,
    required this.orientation,
691
  });
692

693 694
  final Size minSize;
  final Orientation orientation;
695

696 697 698
  @override
  RenderObject createRenderObject(BuildContext context) {
    return _RenderInputPadding(minSize, orientation);
Yegor's avatar
Yegor committed
699
  }
700

701 702
  @override
  void updateRenderObject(BuildContext context, covariant _RenderInputPadding renderObject) {
703 704 705
    renderObject
      ..minSize = minSize
      ..orientation = orientation;
706 707
  }
}
708

709
class _RenderInputPadding extends RenderShiftedBox {
710
  _RenderInputPadding(this._minSize, this._orientation, [RenderBox? child]) : super(child);
711 712 713 714

  Size get minSize => _minSize;
  Size _minSize;
  set minSize(Size value) {
715
    if (_minSize == value) {
716
      return;
717
    }
718 719
    _minSize = value;
    markNeedsLayout();
Yegor's avatar
Yegor committed
720
  }
721

722 723 724 725 726 727 728 729 730 731
  Orientation get orientation => _orientation;
  Orientation _orientation;
  set orientation(Orientation value) {
    if (_orientation == value) {
      return;
    }
    _orientation = value;
    markNeedsLayout();
  }

732 733 734
  @override
  double computeMinIntrinsicWidth(double height) {
    if (child != null) {
735
      return math.max(child!.getMinIntrinsicWidth(height), minSize.width);
Yegor's avatar
Yegor committed
736
    }
737 738
    return 0.0;
  }
739

740 741 742
  @override
  double computeMinIntrinsicHeight(double width) {
    if (child != null) {
743
      return math.max(child!.getMinIntrinsicHeight(width), minSize.height);
Yegor's avatar
Yegor committed
744
    }
745
    return 0.0;
Yegor's avatar
Yegor committed
746
  }
747

748 749 750
  @override
  double computeMaxIntrinsicWidth(double height) {
    if (child != null) {
751
      return math.max(child!.getMaxIntrinsicWidth(height), minSize.width);
752
    }
753
    return 0.0;
754 755 756
  }

  @override
757 758
  double computeMaxIntrinsicHeight(double width) {
    if (child != null) {
759
      return math.max(child!.getMaxIntrinsicHeight(width), minSize.height);
760 761
    }
    return 0.0;
762 763
  }

764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781
  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,
    );
  }

782 783
  @override
  void performLayout() {
784 785 786 787
    size = _computeSize(
      constraints: constraints,
      layoutChild: ChildLayoutHelper.layoutChild,
    );
788
    if (child != null) {
789 790
      final BoxParentData childParentData = child!.parentData! as BoxParentData;
      childParentData.offset = Alignment.center.alongOffset(size - child!.size as Offset);
791 792 793
    }
  }

794
  @override
795
  bool hitTest(BoxHitTestResult result, { required Offset position }) {
796 797 798
    if (super.hitTest(result, position: position)) {
      return true;
    }
Yegor's avatar
Yegor committed
799

800
    if (position.dx < 0.0 ||
801
        position.dx > math.max(child!.size.width, minSize.width) ||
802
        position.dy < 0.0 ||
803
        position.dy > math.max(child!.size.height, minSize.height)) {
804 805
      return false;
    }
Yegor's avatar
Yegor committed
806

807
    Offset newPosition = child!.size.center(Offset.zero);
Yegor's avatar
Yegor committed
808 809
    switch (orientation) {
      case Orientation.portrait:
810 811 812 813 814
        if (position.dy > newPosition.dy) {
          newPosition += const Offset(0.0, 1.0);
        } else {
          newPosition += const Offset(0.0, -1.0);
        }
815
        break;
Yegor's avatar
Yegor committed
816
      case Orientation.landscape:
817 818 819 820 821
        if (position.dx > newPosition.dx) {
          newPosition += const Offset(1.0, 0.0);
        } else {
          newPosition += const Offset(-1.0, 0.0);
        }
822 823
        break;
    }
824 825


826 827 828 829 830
    return result.addWithRawTransform(
      transform: MatrixUtils.forceToPoint(newPosition),
      position: newPosition,
      hitTest: (BoxHitTestResult result, Offset position) {
        assert(position == newPosition);
831
        return child!.hitTest(result, position: newPosition);
832
      },
833
    );
834 835
  }
}
836

837 838
class _TappableLabel {
  _TappableLabel({
839 840 841
    required this.value,
    required this.painter,
    required this.onTap,
842 843 844 845 846 847 848 849 850 851 852 853
  });

  /// 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;
}

854
class _DialPainter extends CustomPainter {
855
  _DialPainter({
856 857 858 859 860 861 862 863
    required this.primaryLabels,
    required this.secondaryLabels,
    required this.backgroundColor,
    required this.accentColor,
    required this.dotColor,
    required this.theta,
    required this.textDirection,
    required this.selectedValue,
864
  }) : super(repaint: PaintingBinding.instance.systemFonts);
865

866 867
  final List<_TappableLabel> primaryLabels;
  final List<_TappableLabel> secondaryLabels;
868 869
  final Color backgroundColor;
  final Color accentColor;
870
  final Color dotColor;
871
  final double theta;
872 873
  final TextDirection textDirection;
  final int selectedValue;
874

875 876
  static const double _labelPadding = 28.0;

877 878 879 880 881 882 883 884 885 886 887
  void dispose() {
    for (final _TappableLabel label in primaryLabels) {
      label.painter.dispose();
    }
    for (final _TappableLabel label in secondaryLabels) {
      label.painter.dispose();
    }
    primaryLabels.clear();
    secondaryLabels.clear();
  }

888
  @override
889
  void paint(Canvas canvas, Size size) {
890
    final double radius = size.shortestSide / 2.0;
891
    final Offset center = Offset(size.width / 2.0, size.height / 2.0);
892
    final Offset centerPoint = center;
893
    canvas.drawCircle(centerPoint, radius, Paint()..color = backgroundColor);
894

895 896 897
    final double labelRadius = radius - _labelPadding;
    Offset getOffsetForTheta(double theta) {
      return center + Offset(labelRadius * math.cos(theta), -labelRadius * math.sin(theta));
898 899
    }

900
    void paintLabels(List<_TappableLabel>? labels) {
901
      if (labels == null) {
Yegor's avatar
Yegor committed
902
        return;
903
      }
904
      final double labelThetaIncrement = -_kTwoPi / labels.length;
905
      double labelTheta = math.pi / 2.0;
906

907
      for (final _TappableLabel label in labels) {
908 909 910
        final TextPainter labelPainter = label.painter;
        final Offset labelOffset = Offset(-labelPainter.width / 2.0, -labelPainter.height / 2.0);
        labelPainter.paint(canvas, getOffsetForTheta(labelTheta) + labelOffset);
911 912 913 914
        labelTheta += labelThetaIncrement;
      }
    }

915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933
    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);
    }
934

935 936 937 938 939 940 941 942
    final Rect focusedRect = Rect.fromCircle(
      center: focusedPoint, radius: focusedRadius,
    );
    canvas
      ..save()
      ..clipPath(Path()..addOval(focusedRect));
    paintLabels(secondaryLabels);
    canvas.restore();
943 944
  }

945
  @override
946
  bool shouldRepaint(_DialPainter oldPainter) {
947 948
    return oldPainter.primaryLabels != primaryLabels
        || oldPainter.secondaryLabels != secondaryLabels
949 950
        || oldPainter.backgroundColor != backgroundColor
        || oldPainter.accentColor != accentColor
951
        || oldPainter.theta != theta;
952 953 954
  }
}

955
class _Dial extends StatefulWidget {
956
  const _Dial({
957 958 959 960 961
    required this.selectedTime,
    required this.mode,
    required this.use24HourDials,
    required this.onChanged,
    required this.onHourSelected,
962 963 964
  }) : assert(selectedTime != null),
       assert(mode != null),
       assert(use24HourDials != null);
965 966 967

  final TimeOfDay selectedTime;
  final _TimePickerMode mode;
968
  final bool use24HourDials;
969
  final ValueChanged<TimeOfDay>? onChanged;
970
  final VoidCallback? onHourSelected;
971

972
  @override
973
  _DialState createState() => _DialState();
974 975
}

976
class _DialState extends State<_Dial> with SingleTickerProviderStateMixin {
977
  @override
978 979
  void initState() {
    super.initState();
980
    _thetaController = AnimationController(
981 982 983
      duration: _kDialAnimateDuration,
      vsync: this,
    );
984
    _thetaTween = Tween<double>(begin: _getThetaForTime(widget.selectedTime));
985
    _theta = _thetaController
986
      .drive(CurveTween(curve: standardEasing))
987 988
      .drive(_thetaTween)
      ..addListener(() => setState(() { /* _theta.value has changed */ }));
989 990
  }

991 992 993
  late ThemeData themeData;
  late MaterialLocalizations localizations;
  late MediaQueryData media;
994
  _DialPainter? painter;
995 996 997 998 999

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    assert(debugCheckHasMediaQuery(context));
1000
    themeData = Theme.of(context);
1001
    localizations = MaterialLocalizations.of(context);
1002
    media = MediaQuery.of(context);
1003 1004
  }

1005
  @override
1006
  void didUpdateWidget(_Dial oldWidget) {
1007
    super.didUpdateWidget(oldWidget);
1008
    if (widget.mode != oldWidget.mode || widget.selectedTime != oldWidget.selectedTime) {
1009
      if (!_dragging) {
Yegor's avatar
Yegor committed
1010
        _animateTo(_getThetaForTime(widget.selectedTime));
1011
      }
Yegor's avatar
Yegor committed
1012
    }
1013 1014
  }

1015 1016 1017
  @override
  void dispose() {
    _thetaController.dispose();
1018
    painter?.dispose();
1019 1020 1021
    super.dispose();
  }

1022 1023 1024
  late Tween<double> _thetaTween;
  late Animation<double> _theta;
  late AnimationController _thetaController;
Adam Barth's avatar
Adam Barth committed
1025 1026 1027 1028 1029 1030 1031
  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) {
1032
    final double currentTheta = _theta.value;
Adam Barth's avatar
Adam Barth committed
1033 1034
    double beginTheta = _nearest(targetTheta, currentTheta, currentTheta + _kTwoPi);
    beginTheta = _nearest(targetTheta, beginTheta, currentTheta - _kTwoPi);
1035 1036 1037 1038 1039 1040
    _thetaTween
      ..begin = beginTheta
      ..end = targetTheta;
    _thetaController
      ..value = 0.0
      ..forward();
1041 1042 1043
  }

  double _getThetaForTime(TimeOfDay time) {
1044
    final int hoursFactor = widget.use24HourDials ? TimeOfDay.hoursPerDay : TimeOfDay.hoursPerPeriod;
1045
    final double fraction = widget.mode == _TimePickerMode.hour
1046
      ? (time.hour / hoursFactor) % hoursFactor
1047
      : (time.minute / TimeOfDay.minutesPerHour) % TimeOfDay.minutesPerHour;
1048
    return (math.pi / 2.0 - fraction * _kTwoPi) % _kTwoPi;
1049 1050
  }

1051
  TimeOfDay _getTimeForTheta(double theta, {bool roundMinutes = false}) {
1052
    final double fraction = (0.25 - (theta % _kTwoPi) / _kTwoPi) % 1.0;
1053
    if (widget.mode == _TimePickerMode.hour) {
1054
      int newHour;
1055
      if (widget.use24HourDials) {
1056
        newHour = (fraction * TimeOfDay.hoursPerDay).round() % TimeOfDay.hoursPerDay;
Yegor's avatar
Yegor committed
1057
      } else {
1058
        newHour = (fraction * TimeOfDay.hoursPerPeriod).round() % TimeOfDay.hoursPerPeriod;
Yegor's avatar
Yegor committed
1059 1060 1061
        newHour = newHour + widget.selectedTime.periodOffset;
      }
      return widget.selectedTime.replacing(hour: newHour);
1062
    } else {
1063 1064 1065 1066 1067 1068
      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);
1069 1070 1071
    }
  }

1072 1073
  TimeOfDay _notifyOnChangedIfNeeded({ bool roundMinutes = false }) {
    final TimeOfDay current = _getTimeForTheta(_theta.value, roundMinutes: roundMinutes);
1074
    if (widget.onChanged == null) {
1075
      return current;
1076 1077
    }
    if (current != widget.selectedTime) {
1078
      widget.onChanged!(current);
1079
    }
1080
    return current;
1081 1082
  }

1083
  void _updateThetaForPan({ bool roundMinutes = false }) {
1084
    setState(() {
1085
      final Offset offset = _position! - _center!;
1086 1087 1088 1089
      double angle = (math.atan2(offset.dx, offset.dy) - math.pi / 2.0) % _kTwoPi;
      if (roundMinutes) {
        angle = _getThetaForTime(_getTimeForTheta(angle, roundMinutes: roundMinutes));
      }
1090
      _thetaTween
Hans Muller's avatar
Hans Muller committed
1091 1092
        ..begin = angle
        ..end = angle; // The controller doesn't animate during the pan gesture.
1093 1094 1095
    });
  }

1096 1097
  Offset? _position;
  Offset? _center;
1098

1099
  void _handlePanStart(DragStartDetails details) {
Adam Barth's avatar
Adam Barth committed
1100 1101
    assert(!_dragging);
    _dragging = true;
1102
    final RenderBox box = context.findRenderObject()! as RenderBox;
1103
    _position = box.globalToLocal(details.globalPosition);
1104
    _center = box.size.center(Offset.zero);
1105 1106 1107 1108
    _updateThetaForPan();
    _notifyOnChangedIfNeeded();
  }

1109
  void _handlePanUpdate(DragUpdateDetails details) {
1110
    _position = _position! + details.delta;
1111 1112 1113 1114
    _updateThetaForPan();
    _notifyOnChangedIfNeeded();
  }

1115
  void _handlePanEnd(DragEndDetails details) {
Adam Barth's avatar
Adam Barth committed
1116 1117
    assert(_dragging);
    _dragging = false;
1118 1119
    _position = null;
    _center = null;
1120
    _animateTo(_getThetaForTime(widget.selectedTime));
1121
    if (widget.mode == _TimePickerMode.hour) {
1122
      widget.onHourSelected?.call();
1123
    }
1124 1125
  }

1126
  void _handleTapUp(TapUpDetails details) {
1127
    final RenderBox box = context.findRenderObject()! as RenderBox;
1128 1129
    _position = box.globalToLocal(details.globalPosition);
    _center = box.size.center(Offset.zero);
1130 1131
    _updateThetaForPan(roundMinutes: true);
    final TimeOfDay newTime = _notifyOnChangedIfNeeded(roundMinutes: true);
1132 1133 1134 1135 1136 1137
    if (widget.mode == _TimePickerMode.hour) {
      if (widget.use24HourDials) {
        _announceToAccessibility(context, localizations.formatDecimal(newTime.hour));
      } else {
        _announceToAccessibility(context, localizations.formatDecimal(newTime.hourOfPeriod));
      }
1138
      widget.onHourSelected?.call();
1139 1140 1141
    } else {
      _announceToAccessibility(context, localizations.formatDecimal(newTime.minute));
    }
1142
    _animateTo(_getThetaForTime(_getTimeForTheta(_theta.value, roundMinutes: true)));
1143 1144 1145 1146 1147 1148 1149
    _dragging = false;
    _position = null;
    _center = null;
  }

  void _selectHour(int hour) {
    _announceToAccessibility(context, localizations.formatDecimal(hour));
1150
    final TimeOfDay time;
1151
    if (widget.mode == _TimePickerMode.hour && widget.use24HourDials) {
1152
      time = TimeOfDay(hour: hour, minute: widget.selectedTime.minute);
1153 1154
    } else {
      if (widget.selectedTime.period == DayPeriod.am) {
1155
        time = TimeOfDay(hour: hour, minute: widget.selectedTime.minute);
1156
      } else {
1157
        time = TimeOfDay(hour: hour + TimeOfDay.hoursPerPeriod, minute: widget.selectedTime.minute);
1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168
      }
    }
    final double angle = _getThetaForTime(time);
    _thetaTween
      ..begin = angle
      ..end = angle;
    _notifyOnChangedIfNeeded();
  }

  void _selectMinute(int minute) {
    _announceToAccessibility(context, localizations.formatDecimal(minute));
1169
    final TimeOfDay time = TimeOfDay(
1170 1171 1172 1173 1174 1175 1176 1177 1178 1179
      hour: widget.selectedTime.hour,
      minute: minute,
    );
    final double angle = _getThetaForTime(time);
    _thetaTween
      ..begin = angle
      ..end = angle;
    _notifyOnChangedIfNeeded();
  }

1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192
  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),
1193 1194
  ];

1195
  static const List<TimeOfDay> _twentyFourHours = <TimeOfDay>[
1196
    TimeOfDay(hour: 0, minute: 0),
1197 1198 1199 1200 1201 1202
    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),
1203 1204 1205 1206 1207
    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),
1208 1209
  ];

1210
  _TappableLabel _buildTappableLabel(TextTheme textTheme, Color color, int value, String label, VoidCallback onTap) {
1211
    final TextStyle style = textTheme.bodyLarge!.copyWith(color: color);
1212
    final double labelScaleFactor = math.min(MediaQuery.of(context).textScaleFactor, 2.0);
1213
    return _TappableLabel(
1214
      value: value,
1215 1216
      painter: TextPainter(
        text: TextSpan(style: style, text: label),
1217
        textDirection: TextDirection.ltr,
1218
        textScaleFactor: labelScaleFactor,
1219 1220 1221
      )..layout(),
      onTap: onTap,
    );
1222 1223
  }

1224
  List<_TappableLabel> _build24HourRing(TextTheme textTheme, Color color) => <_TappableLabel>[
1225
    for (final TimeOfDay timeOfDay in _twentyFourHours)
1226
      _buildTappableLabel(
1227
        textTheme,
1228
        color,
1229 1230 1231 1232 1233
        timeOfDay.hour,
        localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat),
        () {
          _selectHour(timeOfDay.hour);
        },
1234 1235
      ),
  ];
1236

1237
  List<_TappableLabel> _build12HourRing(TextTheme textTheme, Color color) => <_TappableLabel>[
1238
    for (final TimeOfDay timeOfDay in _amHours)
1239
      _buildTappableLabel(
1240
        textTheme,
1241
        color,
1242 1243 1244 1245 1246
        timeOfDay.hour,
        localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat),
        () {
          _selectHour(timeOfDay.hour);
        },
1247 1248
      ),
  ];
1249

1250
  List<_TappableLabel> _buildMinutes(TextTheme textTheme, Color color) {
1251
    const List<TimeOfDay> minuteMarkerValues = <TimeOfDay>[
1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263
      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),
1264 1265
    ];

1266
    return <_TappableLabel>[
1267
      for (final TimeOfDay timeOfDay in minuteMarkerValues)
1268 1269
        _buildTappableLabel(
          textTheme,
1270
          color,
1271 1272 1273 1274 1275 1276 1277
          timeOfDay.minute,
          localizations.formatMinute(timeOfDay),
          () {
            _selectMinute(timeOfDay.minute);
          },
        ),
    ];
1278 1279
  }

1280
  @override
1281
  Widget build(BuildContext context) {
1282
    final ThemeData theme = Theme.of(context);
1283 1284 1285
    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;
1286 1287
    final Color primaryLabelColor = MaterialStateProperty.resolveAs(pickerTheme.dialTextColor, <MaterialState>{}) ?? themeData.colorScheme.onSurface;
    final Color secondaryLabelColor = MaterialStateProperty.resolveAs(pickerTheme.dialTextColor, <MaterialState>{MaterialState.selected}) ?? themeData.colorScheme.onPrimary;
1288 1289
    List<_TappableLabel> primaryLabels;
    List<_TappableLabel> secondaryLabels;
1290
    final int selectedDialValue;
1291
    switch (widget.mode) {
1292
      case _TimePickerMode.hour:
1293
        if (widget.use24HourDials) {
1294
          selectedDialValue = widget.selectedTime.hour;
1295
          primaryLabels = _build24HourRing(theme.textTheme, primaryLabelColor);
1296
          secondaryLabels = _build24HourRing(theme.textTheme, secondaryLabelColor);
1297
        } else {
1298
          selectedDialValue = widget.selectedTime.hourOfPeriod;
1299
          primaryLabels = _build12HourRing(theme.textTheme, primaryLabelColor);
1300
          secondaryLabels = _build12HourRing(theme.textTheme, secondaryLabelColor);
1301
        }
1302 1303
        break;
      case _TimePickerMode.minute:
1304
        selectedDialValue = widget.selectedTime.minute;
1305
        primaryLabels = _buildMinutes(theme.textTheme, primaryLabelColor);
1306
        secondaryLabels = _buildMinutes(theme.textTheme, secondaryLabelColor);
1307 1308 1309
        break;
    }

1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321
    painter?.dispose();
    painter = _DialPainter(
      selectedValue: selectedDialValue,
      primaryLabels: primaryLabels,
      secondaryLabels: secondaryLabels,
      backgroundColor: backgroundColor,
      accentColor: accentColor,
      dotColor: theme.colorScheme.surface,
      theta: _theta.value,
      textDirection: Directionality.of(context),
    );

1322
    return GestureDetector(
1323
      excludeFromSemantics: true,
1324 1325 1326
      onPanStart: _handlePanStart,
      onPanUpdate: _handlePanUpdate,
      onPanEnd: _handlePanEnd,
1327
      onTapUp: _handleTapUp,
1328
      child: CustomPaint(
1329
        key: const ValueKey<String>('time-picker-dial'),
1330
        painter: painter,
1331
      ),
1332 1333 1334
    );
  }
}
1335

1336 1337
class _TimePickerInput extends StatefulWidget {
  const _TimePickerInput({
1338 1339
    required this.initialSelectedTime,
    required this.helpText,
1340 1341 1342
    required this.errorInvalidText,
    required this.hourLabelText,
    required this.minuteLabelText,
1343 1344 1345
    required this.autofocusHour,
    required this.autofocusMinute,
    required this.onChanged,
1346
    this.restorationId,
1347
  }) : assert(initialSelectedTime != null),
1348
       assert(onChanged != null);
1349 1350 1351 1352 1353

  /// The time initially selected when the dialog is shown.
  final TimeOfDay initialSelectedTime;

  /// Optionally provide your own help text to the time picker.
1354
  final String? helpText;
1355

1356 1357 1358 1359 1360 1361 1362 1363 1364
  /// 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;

1365
  final bool? autofocusHour;
1366

1367
  final bool? autofocusMinute;
1368

1369 1370
  final ValueChanged<TimeOfDay> onChanged;

1371 1372 1373 1374 1375 1376 1377 1378 1379
  /// 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;

1380 1381 1382 1383
  @override
  _TimePickerInputState createState() => _TimePickerInputState();
}

1384 1385 1386 1387
class _TimePickerInputState extends State<_TimePickerInput> with RestorationMixin {
  late final RestorableTimeOfDay _selectedTime = RestorableTimeOfDay(widget.initialSelectedTime);
  final RestorableBool hourHasError = RestorableBool(false);
  final RestorableBool minuteHasError = RestorableBool(false);
1388 1389

  @override
1390 1391 1392 1393 1394 1395 1396
  String? get restorationId => widget.restorationId;

  @override
  void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
    registerForRestoration(_selectedTime, 'selected_time');
    registerForRestoration(hourHasError, 'hour_has_error');
    registerForRestoration(minuteHasError, 'minute_has_error');
1397 1398
  }

1399
  int? _parseHour(String? value) {
1400 1401 1402 1403
    if (value == null) {
      return null;
    }

1404
    int? newHour = int.tryParse(value);
1405 1406 1407 1408
    if (newHour == null) {
      return null;
    }

1409
    if (MediaQuery.of(context).alwaysUse24HourFormat) {
1410 1411 1412 1413 1414
      if (newHour >= 0 && newHour < 24) {
        return newHour;
      }
    } else {
      if (newHour > 0 && newHour < 13) {
1415 1416
        if ((_selectedTime.value.period == DayPeriod.pm && newHour != 12)
            || (_selectedTime.value.period == DayPeriod.am && newHour == 12)) {
1417 1418 1419 1420 1421 1422 1423 1424
          newHour = (newHour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay;
        }
        return newHour;
      }
    }
    return null;
  }

1425
  int? _parseMinute(String? value) {
1426 1427 1428 1429
    if (value == null) {
      return null;
    }

1430
    final int? newMinute = int.tryParse(value);
1431 1432 1433 1434 1435 1436 1437 1438 1439 1440
    if (newMinute == null) {
      return null;
    }

    if (newMinute >= 0 && newMinute < 60) {
      return newMinute;
    }
    return null;
  }

1441 1442
  void _handleHourSavedSubmitted(String? value) {
    final int? newHour = _parseHour(value);
1443
    if (newHour != null) {
1444 1445
      _selectedTime.value = TimeOfDay(hour: newHour, minute: _selectedTime.value.minute);
      widget.onChanged(_selectedTime.value);
1446
      FocusScope.of(context).requestFocus();
1447 1448 1449 1450
    }
  }

  void _handleHourChanged(String value) {
1451
    final int? newHour = _parseHour(value);
1452 1453 1454 1455 1456 1457
    if (newHour != null && value.length == 2) {
      // If a valid hour is typed, move focus to the minute TextField.
      FocusScope.of(context).nextFocus();
    }
  }

1458 1459
  void _handleMinuteSavedSubmitted(String? value) {
    final int? newMinute = _parseMinute(value);
1460
    if (newMinute != null) {
1461 1462
      _selectedTime.value = TimeOfDay(hour: _selectedTime.value.hour, minute: int.parse(value!));
      widget.onChanged(_selectedTime.value);
1463
      FocusScope.of(context).unfocus();
1464 1465 1466 1467
    }
  }

  void _handleDayPeriodChanged(TimeOfDay value) {
1468 1469
    _selectedTime.value = value;
    widget.onChanged(_selectedTime.value);
1470 1471
  }

1472 1473
  String? _validateHour(String? value) {
    final int? newHour = _parseHour(value);
1474
    setState(() {
1475
      hourHasError.value = newHour == null;
1476 1477 1478 1479 1480 1481 1482
    });
    // 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;
  }

1483 1484
  String? _validateMinute(String? value) {
    final int? newMinute = _parseMinute(value);
1485
    setState(() {
1486
      minuteHasError.value = newMinute == null;
1487 1488 1489 1490 1491 1492 1493 1494 1495 1496
    });
    // 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));
1497
    final MediaQueryData media = MediaQuery.of(context);
1498
    final TimeOfDayFormat timeOfDayFormat = MaterialLocalizations.of(context).timeOfDayFormat(alwaysUse24HourFormat: media.alwaysUse24HourFormat);
1499
    final bool use24HourDials = hourFormat(of: timeOfDayFormat) != HourFormat.h;
1500
    final ThemeData theme = Theme.of(context);
1501
    final TextStyle hourMinuteStyle = TimePickerTheme.of(context).hourMinuteTextStyle ?? theme.textTheme.displayMedium!;
1502 1503 1504 1505
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
    final String timePickerInputHelpText = theme.useMaterial3
      ? localizations.timePickerInputHelpText
      : localizations.timePickerInputHelpText.toUpperCase();
1506 1507 1508 1509 1510 1511 1512

    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Text(
1513
            widget.helpText ?? timePickerInputHelpText,
1514
            style: TimePickerTheme.of(context).helpTextStyle ?? theme.textTheme.labelSmall,
1515 1516 1517 1518 1519 1520 1521
          ),
          const SizedBox(height: 16.0),
          Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              if (!use24HourDials && timeOfDayFormat == TimeOfDayFormat.a_space_h_colon_mm) ...<Widget>[
                _DayPeriodControl(
1522
                  selectedTime: _selectedTime.value,
1523 1524 1525 1526 1527
                  orientation: Orientation.portrait,
                  onChanged: _handleDayPeriodChanged,
                ),
                const SizedBox(width: 12.0),
              ],
1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539
              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(
1540 1541
                            restorationId: 'hour_text_field',
                            selectedTime: _selectedTime.value,
1542
                            style: hourMinuteStyle,
1543
                            autofocus: widget.autofocusHour,
1544
                            inputAction: TextInputAction.next,
1545 1546 1547
                            validator: _validateHour,
                            onSavedSubmitted: _handleHourSavedSubmitted,
                            onChanged: _handleHourChanged,
1548
                            hourLabelText: widget.hourLabelText,
1549 1550
                          ),
                          const SizedBox(height: 8.0),
1551
                          if (!hourHasError.value && !minuteHasError.value)
1552 1553
                            ExcludeSemantics(
                              child: Text(
1554
                                widget.hourLabelText ?? MaterialLocalizations.of(context).timePickerHourLabel,
1555
                                style: theme.textTheme.bodySmall,
1556 1557 1558 1559 1560
                                maxLines: 1,
                                overflow: TextOverflow.ellipsis,
                              ),
                            ),
                        ],
1561
                      ),
1562
                    ),
1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573
                    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(
1574 1575
                            restorationId: 'minute_text_field',
                            selectedTime: _selectedTime.value,
1576
                            style: hourMinuteStyle,
1577
                            autofocus: widget.autofocusMinute,
1578
                            inputAction: TextInputAction.done,
1579 1580
                            validator: _validateMinute,
                            onSavedSubmitted: _handleMinuteSavedSubmitted,
1581
                            minuteLabelText: widget.minuteLabelText,
1582 1583
                          ),
                          const SizedBox(height: 8.0),
1584
                          if (!hourHasError.value && !minuteHasError.value)
1585 1586
                            ExcludeSemantics(
                              child: Text(
1587
                                widget.minuteLabelText ?? MaterialLocalizations.of(context).timePickerMinuteLabel,
1588
                                style: theme.textTheme.bodySmall,
1589 1590 1591 1592 1593
                                maxLines: 1,
                                overflow: TextOverflow.ellipsis,
                              ),
                            ),
                        ],
1594
                      ),
1595
                    ),
1596 1597 1598
                  ],
                ),
              ),
1599 1600 1601
              if (!use24HourDials && timeOfDayFormat != TimeOfDayFormat.a_space_h_colon_mm) ...<Widget>[
                const SizedBox(width: 12.0),
                _DayPeriodControl(
1602
                  selectedTime: _selectedTime.value,
1603 1604 1605 1606 1607 1608
                  orientation: Orientation.portrait,
                  onChanged: _handleDayPeriodChanged,
                ),
              ],
            ],
          ),
1609
          if (hourHasError.value || minuteHasError.value)
1610
            Text(
1611
              widget.errorInvalidText ?? MaterialLocalizations.of(context).invalidTimeLabel,
1612
              style: theme.textTheme.bodyMedium!.copyWith(color: theme.colorScheme.error),
1613 1614 1615 1616 1617 1618 1619 1620 1621
            )
          else
            const SizedBox(height: 2.0),
        ],
      ),
    );
  }
}

1622 1623
class _HourTextField extends StatelessWidget {
  const _HourTextField({
1624 1625 1626
    required this.selectedTime,
    required this.style,
    required this.autofocus,
1627
    required this.inputAction,
1628 1629 1630
    required this.validator,
    required this.onSavedSubmitted,
    required this.onChanged,
1631
    required this.hourLabelText,
1632
    this.restorationId,
1633
  });
1634 1635 1636

  final TimeOfDay selectedTime;
  final TextStyle style;
1637
  final bool? autofocus;
1638
  final TextInputAction inputAction;
1639
  final FormFieldValidator<String> validator;
1640
  final ValueChanged<String?> onSavedSubmitted;
1641
  final ValueChanged<String> onChanged;
1642
  final String? hourLabelText;
1643
  final String? restorationId;
1644 1645 1646 1647

  @override
  Widget build(BuildContext context) {
    return _HourMinuteTextField(
1648
      restorationId: restorationId,
1649 1650
      selectedTime: selectedTime,
      isHour: true,
1651
      autofocus: autofocus,
1652
      inputAction: inputAction,
1653
      style: style,
1654
      semanticHintText: hourLabelText ??  MaterialLocalizations.of(context).timePickerHourLabel,
1655 1656 1657 1658 1659 1660 1661 1662 1663
      validator: validator,
      onSavedSubmitted: onSavedSubmitted,
      onChanged: onChanged,
    );
  }
}

class _MinuteTextField extends StatelessWidget {
  const _MinuteTextField({
1664 1665 1666
    required this.selectedTime,
    required this.style,
    required this.autofocus,
1667
    required this.inputAction,
1668 1669
    required this.validator,
    required this.onSavedSubmitted,
1670
    required this.minuteLabelText,
1671
    this.restorationId,
1672
  });
1673 1674 1675

  final TimeOfDay selectedTime;
  final TextStyle style;
1676
  final bool? autofocus;
1677
  final TextInputAction inputAction;
1678
  final FormFieldValidator<String> validator;
1679
  final ValueChanged<String?> onSavedSubmitted;
1680
  final String? minuteLabelText;
1681
  final String? restorationId;
1682 1683 1684 1685

  @override
  Widget build(BuildContext context) {
    return _HourMinuteTextField(
1686
      restorationId: restorationId,
1687 1688
      selectedTime: selectedTime,
      isHour: false,
1689
      autofocus: autofocus,
1690
      inputAction: inputAction,
1691
      style: style,
1692
      semanticHintText: minuteLabelText ?? MaterialLocalizations.of(context).timePickerMinuteLabel,
1693 1694 1695 1696 1697 1698
      validator: validator,
      onSavedSubmitted: onSavedSubmitted,
    );
  }
}

1699 1700
class _HourMinuteTextField extends StatefulWidget {
  const _HourMinuteTextField({
1701 1702 1703
    required this.selectedTime,
    required this.isHour,
    required this.autofocus,
1704
    required this.inputAction,
1705 1706 1707 1708
    required this.style,
    required this.semanticHintText,
    required this.validator,
    required this.onSavedSubmitted,
1709
    this.restorationId,
1710
    this.onChanged,
1711
  });
1712 1713 1714

  final TimeOfDay selectedTime;
  final bool isHour;
1715
  final bool? autofocus;
1716
  final TextInputAction inputAction;
1717
  final TextStyle style;
1718
  final String semanticHintText;
1719
  final FormFieldValidator<String> validator;
1720 1721
  final ValueChanged<String?> onSavedSubmitted;
  final ValueChanged<String>? onChanged;
1722
  final String? restorationId;
1723 1724 1725 1726 1727

  @override
  _HourMinuteTextFieldState createState() => _HourMinuteTextFieldState();
}

1728 1729 1730
class _HourMinuteTextFieldState extends State<_HourMinuteTextField> with RestorationMixin {
  final RestorableTextEditingController controller = RestorableTextEditingController();
  final RestorableBool controllerHasBeenSet = RestorableBool(false);
1731
  late FocusNode focusNode;
1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743

  @override
  void initState() {
    super.initState();
    focusNode = FocusNode()..addListener(() {
      setState(() { }); // Rebuild.
    });
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758
    // 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');
1759 1760 1761
  }

  String get _formattedValue {
1762
    final bool alwaysUse24HourFormat = MediaQuery.of(context).alwaysUse24HourFormat;
1763
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
1764 1765 1766 1767 1768 1769 1770 1771
    return !widget.isHour ? localizations.formatMinute(widget.selectedTime) : localizations.formatHour(
      widget.selectedTime,
      alwaysUse24HourFormat: alwaysUse24HourFormat,
    );
  }

  @override
  Widget build(BuildContext context) {
1772
    final ThemeData theme = Theme.of(context);
1773
    final TimePickerThemeData timePickerTheme = TimePickerTheme.of(context);
1774 1775
    final ColorScheme colorScheme = theme.colorScheme;

1776
    final InputDecorationTheme? inputDecorationTheme = timePickerTheme.inputDecorationTheme;
1777 1778 1779 1780 1781
    InputDecoration inputDecoration;
    if (inputDecorationTheme != null) {
      inputDecoration = const InputDecoration().applyDefaults(inputDecorationTheme);
    } else {
      inputDecoration = InputDecoration(
1782
        contentPadding: EdgeInsets.zero,
1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800
        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.
      );
    }
1801
    final Color unfocusedFillColor = timePickerTheme.hourMinuteColor ?? colorScheme.onSurface.withOpacity(0.12);
1802 1803 1804
    // 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.
1805 1806 1807
    //
    // TODO(rami-a): Once https://github.com/flutter/flutter/issues/67571 is
    // resolved, remove the window check for semantics being enabled on web.
1808
    final String? hintText = MediaQuery.of(context).accessibleNavigation || WidgetsBinding.instance.window.semanticsEnabled
1809 1810
        ? widget.semanticHintText
        : (focusNode.hasFocus ? null : _formattedValue);
1811
    inputDecoration = inputDecoration.copyWith(
1812
      hintText: hintText,
1813
      fillColor: focusNode.hasFocus ? Colors.transparent : inputDecorationTheme?.fillColor ?? unfocusedFillColor,
1814 1815
    );

1816 1817 1818
    return SizedBox(
      height: _kTimePickerHeaderControlHeight,
      child: MediaQuery(
1819
        data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0),
1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831
        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,
1832
            textInputAction: widget.inputAction,
1833 1834 1835 1836 1837 1838 1839 1840 1841 1842
            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,
          ),
1843
        ),
1844
      ),
1845 1846 1847 1848
    );
  }
}

1849 1850 1851
/// Signature for when the time picker entry mode is changed.
typedef EntryModeChangeCallback = void Function(TimePickerEntryMode);

1852
/// A Material Design time picker designed to appear inside a popup dialog.
1853 1854 1855 1856 1857
///
/// 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].
1858
class TimePickerDialog extends StatefulWidget {
1859
  /// Creates a Material Design time picker.
1860 1861
  ///
  /// [initialTime] must not be null.
1862
  const TimePickerDialog({
1863
    super.key,
1864
    required this.initialTime,
1865 1866 1867
    this.cancelText,
    this.confirmText,
    this.helpText,
1868 1869 1870
    this.errorInvalidText,
    this.hourLabelText,
    this.minuteLabelText,
1871
    this.restorationId,
1872
    this.initialEntryMode = TimePickerEntryMode.dial,
1873
    this.onEntryModeChanged,
1874
  }) : assert(initialTime != null);
1875

1876
  /// The time initially selected when the dialog is shown.
1877 1878
  final TimeOfDay initialTime;

1879 1880 1881 1882 1883 1884
  /// 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].
1885
  final String? cancelText;
1886 1887 1888 1889

  /// Optionally provide your own text for the confirm button.
  ///
  /// If null, the button uses [MaterialLocalizations.okButtonLabel].
1890
  final String? confirmText;
1891 1892

  /// Optionally provide your own help text to the header of the time picker.
1893
  final String? helpText;
1894

1895 1896 1897 1898 1899 1900 1901 1902 1903
  /// 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;

1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917
  /// 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;

1918 1919 1920
  /// Callback called when the selected entry mode is changed.
  final EntryModeChangeCallback? onEntryModeChanged;

1921
  @override
1922
  State<TimePickerDialog> createState() => _TimePickerDialogState();
1923 1924
}

1925 1926 1927 1928 1929 1930 1931 1932 1933
// 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;
1934

1935
  @override
1936 1937 1938 1939 1940 1941
  TimePickerEntryMode createDefaultValue() => _defaultValue;

  @override
  void didUpdateValue(TimePickerEntryMode? oldValue) {
    assert(debugIsSerializableForRestoration(value.index));
    notifyListeners();
1942 1943
  }

1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976
  @override
  TimePickerEntryMode fromPrimitives(Object? data) => TimePickerEntryMode.values[data! as int];

  @override
  Object? toPrimitives() => value.index;
}

// A restorable [_RestorableTimePickerEntryMode] value.
//
// This serializes each entry as a unique `int` value.
class _RestorableTimePickerMode extends RestorableValue<_TimePickerMode> {
  _RestorableTimePickerMode(
    _TimePickerMode defaultValue,
  ) : _defaultValue = defaultValue;

  final _TimePickerMode _defaultValue;

  @override
  _TimePickerMode createDefaultValue() => _defaultValue;

  @override
  void didUpdateValue(_TimePickerMode? oldValue) {
    assert(debugIsSerializableForRestoration(value.index));
    notifyListeners();
  }

  @override
  _TimePickerMode fromPrimitives(Object? data) => _TimePickerMode.values[data! as int];

  @override
  Object? toPrimitives() => value.index;
}

1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002
// 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;
}

2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036
// A restorable [_RestorableTimePickerEntryMode] value.
//
// This serializes each entry as a unique `int` value.
//
// This value can be null.
class _RestorableTimePickerModeN extends RestorableValue<_TimePickerMode?> {
  _RestorableTimePickerModeN(
    _TimePickerMode? defaultValue,
  ) : _defaultValue = defaultValue;

  final _TimePickerMode? _defaultValue;

  @override
  _TimePickerMode? createDefaultValue() => _defaultValue;

  @override
  void didUpdateValue(_TimePickerMode? oldValue) {
    assert(debugIsSerializableForRestoration(value?.index));
    notifyListeners();
  }

  @override
  _TimePickerMode fromPrimitives(Object? data) => _TimePickerMode.values[data! as int];

  @override
  Object? toPrimitives() => value?.index;
}

class _TimePickerDialogState extends State<TimePickerDialog> with RestorationMixin {
  final GlobalKey<FormState> _formKey = GlobalKey<FormState>();

  late final _RestorableTimePickerEntryMode _entryMode = _RestorableTimePickerEntryMode(widget.initialEntryMode);
  final _RestorableTimePickerMode _mode = _RestorableTimePickerMode(_TimePickerMode.hour);
  final _RestorableTimePickerModeN _lastModeAnnounced = _RestorableTimePickerModeN(null);
2037
  final _RestorableAutovalidateMode _autovalidateMode = _RestorableAutovalidateMode(AutovalidateMode.disabled);
2038 2039 2040 2041
  final RestorableBoolN _autofocusHour = RestorableBoolN(null);
  final RestorableBoolN _autofocusMinute = RestorableBoolN(null);
  final RestorableBool _announcedInitialTime = RestorableBool(false);

2042 2043
  late final VoidCallback _entryModeListener;

2044 2045 2046
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
2047
    localizations = MaterialLocalizations.of(context);
2048 2049 2050 2051
    _announceInitialTimeOnce();
    _announceModeOnce();
  }

2052 2053 2054 2055 2056 2057 2058
  @override
  void initState() {
    super.initState();
    _entryModeListener = () => widget.onEntryModeChanged?.call(_entryMode.value);
    _entryMode.addListener(_entryModeListener);
  }

2059 2060 2061 2062 2063 2064 2065 2066
  @override
  String? get restorationId => widget.restorationId;

  @override
  void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
    registerForRestoration(_entryMode, 'entry_mode');
    registerForRestoration(_mode, 'mode');
    registerForRestoration(_lastModeAnnounced, 'last_mode_announced');
2067
    registerForRestoration(_autovalidateMode, 'autovalidateMode');
2068 2069 2070 2071 2072
    registerForRestoration(_autofocusHour, 'autofocus_hour');
    registerForRestoration(_autofocusMinute, 'autofocus_minute');
    registerForRestoration(_announcedInitialTime, 'announced_initial_time');
    registerForRestoration(_selectedTime, 'selected_time');
  }
2073

2074 2075
  RestorableTimeOfDay get selectedTime => _selectedTime;
  late final RestorableTimeOfDay _selectedTime = RestorableTimeOfDay(widget.initialTime);
2076

2077 2078
  Timer? _vibrateTimer;
  late MaterialLocalizations localizations;
2079

2080
  void _vibrate() {
2081
    switch (Theme.of(context).platform) {
2082 2083
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
2084 2085
      case TargetPlatform.linux:
      case TargetPlatform.windows:
2086
        _vibrateTimer?.cancel();
2087
        _vibrateTimer = Timer(_kVibrateCommitDelay, () {
2088 2089 2090
          HapticFeedback.vibrate();
          _vibrateTimer = null;
        });
2091 2092
        break;
      case TargetPlatform.iOS:
2093
      case TargetPlatform.macOS:
2094 2095 2096 2097
        break;
    }
  }

2098
  void _handleModeChanged(_TimePickerMode mode) {
2099
    _vibrate();
2100
    setState(() {
2101
      _mode.value = mode;
2102
      _announceModeOnce();
2103 2104 2105
    });
  }

2106 2107
  void _handleEntryModeToggle() {
    setState(() {
2108
      switch (_entryMode.value) {
2109
        case TimePickerEntryMode.dial:
2110
          _autovalidateMode.value = AutovalidateMode.disabled;
2111
          _entryMode.value = TimePickerEntryMode.input;
2112 2113
          break;
        case TimePickerEntryMode.input:
2114
          _formKey.currentState!.save();
2115 2116 2117
          _autofocusHour.value = false;
          _autofocusMinute.value = false;
          _entryMode.value = TimePickerEntryMode.dial;
2118
          break;
2119 2120 2121 2122
        case TimePickerEntryMode.dialOnly:
        case TimePickerEntryMode.inputOnly:
          FlutterError('Can not change entry mode from $_entryMode');
          break;
2123 2124 2125 2126
      }
    });
  }

2127
  void _announceModeOnce() {
2128
    if (_lastModeAnnounced.value == _mode.value) {
2129 2130 2131 2132
      // Already announced it.
      return;
    }

2133
    switch (_mode.value) {
2134 2135 2136 2137 2138 2139 2140
      case _TimePickerMode.hour:
        _announceToAccessibility(context, localizations.timePickerHourModeAnnouncement);
        break;
      case _TimePickerMode.minute:
        _announceToAccessibility(context, localizations.timePickerMinuteModeAnnouncement);
        break;
    }
2141
    _lastModeAnnounced.value = _mode.value;
2142 2143 2144
  }

  void _announceInitialTimeOnce() {
2145
    if (_announcedInitialTime.value) {
2146
      return;
2147
    }
2148

2149
    final MediaQueryData media = MediaQuery.of(context);
2150
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
2151 2152 2153 2154
    _announceToAccessibility(
      context,
      localizations.formatTimeOfDay(widget.initialTime, alwaysUse24HourFormat: media.alwaysUse24HourFormat),
    );
2155
    _announcedInitialTime.value = true;
2156 2157
  }

2158
  void _handleTimeChanged(TimeOfDay value) {
2159
    _vibrate();
2160
    setState(() {
2161
      _selectedTime.value = value;
2162 2163 2164
    });
  }

2165
  void _handleHourDoubleTapped() {
2166
    _autofocusHour.value = true;
2167 2168 2169 2170
    _handleEntryModeToggle();
  }

  void _handleMinuteDoubleTapped() {
2171
    _autofocusMinute.value = true;
2172 2173 2174
    _handleEntryModeToggle();
  }

2175 2176
  void _handleHourSelected() {
    setState(() {
2177
      _mode.value = _TimePickerMode.minute;
2178 2179 2180
    });
  }

2181 2182 2183 2184 2185
  void _handleCancel() {
    Navigator.pop(context);
  }

  void _handleOk() {
2186
    if (_entryMode.value == TimePickerEntryMode.input || _entryMode.value == TimePickerEntryMode.inputOnly) {
2187
      final FormState form = _formKey.currentState!;
2188
      if (!form.validate()) {
2189
        setState(() { _autovalidateMode.value = AutovalidateMode.always; });
2190 2191 2192 2193
        return;
      }
      form.save();
    }
2194
    Navigator.pop(context, _selectedTime.value);
2195 2196
  }

2197
  Size _dialogSize(BuildContext context) {
2198
    final Orientation orientation = MediaQuery.of(context).orientation;
2199
    final ThemeData theme = Theme.of(context);
2200 2201 2202
    // 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.
2203
    final double textScaleFactor = math.min(MediaQuery.of(context).textScaleFactor, 1.1);
2204

2205 2206
    final double timePickerWidth;
    final double timePickerHeight;
2207
    switch (_entryMode.value) {
2208
      case TimePickerEntryMode.dial:
2209
      case TimePickerEntryMode.dialOnly:
2210 2211 2212 2213 2214 2215 2216 2217 2218 2219 2220 2221 2222 2223 2224 2225
        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:
2226
      case TimePickerEntryMode.inputOnly:
2227 2228 2229 2230 2231 2232 2233
        timePickerWidth = _kTimePickerWidthPortrait;
        timePickerHeight = _kTimePickerHeightInput;
        break;
    }
    return Size(timePickerWidth, timePickerHeight * textScaleFactor);
  }

2234 2235
  @override
  Widget build(BuildContext context) {
2236
    assert(debugCheckHasMediaQuery(context));
2237
    final MediaQueryData media = MediaQuery.of(context);
2238
    final TimeOfDayFormat timeOfDayFormat = localizations.timeOfDayFormat(alwaysUse24HourFormat: media.alwaysUse24HourFormat);
2239
    final bool use24HourDials = hourFormat(of: timeOfDayFormat) != HourFormat.h;
2240
    final ThemeData theme = Theme.of(context);
2241 2242
    final ShapeBorder shape = TimePickerTheme.of(context).shape ?? _kDefaultShape;
    final Orientation orientation = media.orientation;
Yegor's avatar
Yegor committed
2243

2244
    final Widget actions = Row(
2245
      children: <Widget>[
2246
        const SizedBox(width: 10.0),
2247 2248 2249 2250 2251 2252 2253 2254 2255 2256
        if (_entryMode.value == TimePickerEntryMode.dial || _entryMode.value == TimePickerEntryMode.input)
          IconButton(
            color: TimePickerTheme.of(context).entryModeIconColor ?? theme.colorScheme.onSurface.withOpacity(
              theme.colorScheme.brightness == Brightness.dark ? 1.0 : 0.6,
            ),
            onPressed: _handleEntryModeToggle,
            icon: Icon(_entryMode.value == TimePickerEntryMode.dial ? Icons.keyboard : Icons.access_time),
            tooltip: _entryMode.value == TimePickerEntryMode.dial
                ? MaterialLocalizations.of(context).inputTimeModeButtonLabel
                : MaterialLocalizations.of(context).dialModeButtonLabel,
2257 2258
          ),
        Expanded(
2259 2260 2261 2262 2263 2264 2265 2266 2267 2268
          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: _handleCancel,
2269 2270 2271 2272 2273
                  child: Text(widget.cancelText ?? (
                    theme.useMaterial3
                      ? localizations.cancelButtonLabel
                      : localizations.cancelButtonLabel.toUpperCase()
                  )),
2274 2275 2276 2277 2278 2279 2280
                ),
                TextButton(
                  onPressed: _handleOk,
                  child: Text(widget.confirmText ?? localizations.okButtonLabel),
                ),
              ],
            ),
2281
          ),
2282 2283
        ),
      ],
2284 2285
    );

2286
    final Widget picker;
2287
    switch (_entryMode.value) {
2288
      case TimePickerEntryMode.dial:
2289
      case TimePickerEntryMode.dialOnly:
2290 2291 2292 2293 2294 2295
        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(
2296
                mode: _mode.value,
2297
                use24HourDials: use24HourDials,
2298
                selectedTime: _selectedTime.value,
2299 2300 2301 2302 2303 2304 2305 2306
                onChanged: _handleTimeChanged,
                onHourSelected: _handleHourSelected,
              ),
            ),
          ),
        );

        final Widget header = _TimePickerHeader(
2307 2308
          selectedTime: _selectedTime.value,
          mode: _mode.value,
2309 2310 2311
          orientation: orientation,
          onModeChanged: _handleModeChanged,
          onChanged: _handleTimeChanged,
2312 2313
          onHourDoubleTapped: _handleHourDoubleTapped,
          onMinuteDoubleTapped: _handleMinuteDoubleTapped,
2314 2315 2316 2317 2318 2319 2320 2321 2322 2323 2324 2325 2326 2327 2328 2329 2330 2331 2332 2333 2334 2335 2336 2337 2338 2339 2340 2341 2342 2343 2344 2345 2346 2347 2348 2349 2350 2351 2352 2353 2354 2355
          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:
2356
      case TimePickerEntryMode.inputOnly:
2357 2358
        picker = Form(
          key: _formKey,
2359
          autovalidateMode: _autovalidateMode.value,
2360
          child: SingleChildScrollView(
2361
            restorationId: 'time_picker_scroll_view',
2362
            child: Column(
2363 2364
              mainAxisSize: MainAxisSize.min,
              children: <Widget>[
2365
                _TimePickerInput(
2366
                  initialSelectedTime: _selectedTime.value,
2367
                  helpText: widget.helpText,
2368 2369 2370
                  errorInvalidText: widget.errorInvalidText,
                  hourLabelText: widget.hourLabelText,
                  minuteLabelText: widget.minuteLabelText,
2371 2372
                  autofocusHour: _autofocusHour.value,
                  autofocusMinute: _autofocusMinute.value,
2373
                  onChanged: _handleTimeChanged,
2374
                  restorationId: 'time_picker_input',
2375
                ),
2376 2377 2378
                actions,
              ],
            ),
2379 2380 2381 2382
          ),
        );
        break;
    }
2383

2384 2385 2386 2387 2388 2389
    final Size dialogSize = _dialogSize(context);
    return Dialog(
      shape: shape,
      backgroundColor: TimePickerTheme.of(context).backgroundColor ?? theme.colorScheme.surface,
      insetPadding: EdgeInsets.symmetric(
        horizontal: 16.0,
2390
        vertical: (_entryMode.value == TimePickerEntryMode.input || _entryMode.value == TimePickerEntryMode.inputOnly) ? 0.0 : 24.0,
2391 2392 2393 2394 2395 2396 2397
      ),
      child: AnimatedContainer(
        width: dialogSize.width,
        height: dialogSize.height,
        duration: _kDialogSizeAnimationDuration,
        curve: Curves.easeIn,
        child: picker,
2398 2399
      ),
    );
2400
  }
2401 2402 2403 2404 2405

  @override
  void dispose() {
    _vibrateTimer?.cancel();
    _vibrateTimer = null;
2406
    _entryMode.removeListener(_entryModeListener);
2407 2408
    super.dispose();
  }
2409 2410
}

2411
/// Shows a dialog containing a Material Design time picker.
2412 2413
///
/// The returned Future resolves to the time selected by the user when the user
2414
/// closes the dialog. If the user cancels the dialog, null is returned.
2415
///
2416
/// {@tool snippet}
2417
/// Show a dialog with [initialTime] equal to the current time.
Ian Hickson's avatar
Ian Hickson committed
2418
///
2419
/// ```dart
2420
/// Future<TimeOfDay?> selectedTime = showTimePicker(
2421
///   initialTime: TimeOfDay.now(),
Ian Hickson's avatar
Ian Hickson committed
2422
///   context: context,
2423 2424
/// );
/// ```
2425
/// {@end-tool}
2426
///
2427 2428
/// The [context], [useRootNavigator] and [routeSettings] arguments are passed to
/// [showDialog], the documentation for which discusses how it is used.
Ian Hickson's avatar
Ian Hickson committed
2429
///
2430 2431 2432 2433
/// The [builder] parameter can be used to wrap the dialog widget
/// to add inherited widgets like [Localizations.override],
/// [Directionality], or [MediaQuery].
///
2434
/// The `initialEntryMode` parameter can be used to
2435 2436 2437
/// determine the initial time entry selection of the picker (either a clock
/// dial or text input).
///
2438 2439 2440
/// Optional strings for the [helpText], [cancelText], [errorInvalidText],
/// [hourLabelText], [minuteLabelText] and [confirmText] can be provided to
/// override the default values.
2441
///
2442 2443
/// {@macro flutter.widgets.RawDialogRoute}
///
2444 2445 2446 2447
/// By default, the time picker gets its colors from the overall theme's
/// [ColorScheme]. The time picker can be further customized by providing a
/// [TimePickerThemeData] to the overall theme.
///
2448
/// {@tool snippet}
2449 2450 2451
/// Show a dialog with the text direction overridden to be [TextDirection.rtl].
///
/// ```dart
2452
/// Future<TimeOfDay?> selectedTimeRTL = showTimePicker(
2453 2454
///   context: context,
///   initialTime: TimeOfDay.now(),
2455
///   builder: (BuildContext context, Widget? child) {
2456 2457
///     return Directionality(
///       textDirection: TextDirection.rtl,
2458
///       child: child!,
2459 2460 2461 2462 2463 2464
///     );
///   },
/// );
/// ```
/// {@end-tool}
///
2465
/// {@tool snippet}
2466 2467 2468
/// Show a dialog with time unconditionally displayed in 24 hour format.
///
/// ```dart
2469
/// Future<TimeOfDay?> selectedTime24Hour = showTimePicker(
2470
///   context: context,
2471
///   initialTime: const TimeOfDay(hour: 10, minute: 47),
2472
///   builder: (BuildContext context, Widget? child) {
2473 2474
///     return MediaQuery(
///       data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true),
2475
///       child: child!,
2476 2477 2478 2479 2480 2481
///     );
///   },
/// );
/// ```
/// {@end-tool}
///
2482 2483
/// See also:
///
2484
///  * [showDatePicker], which shows a dialog that contains a Material Design
2485
///    date picker.
2486 2487
///  * [TimePickerThemeData], which allows you to customize the colors,
///    typography, and shape of the time picker.
2488 2489
///  * [DisplayFeatureSubScreen], which documents the specifics of how
///    [DisplayFeature]s can split the screen into sub-screens.
2490
Future<TimeOfDay?> showTimePicker({
2491 2492 2493
  required BuildContext context,
  required TimeOfDay initialTime,
  TransitionBuilder? builder,
2494
  bool useRootNavigator = true,
2495
  TimePickerEntryMode initialEntryMode = TimePickerEntryMode.dial,
2496 2497 2498
  String? cancelText,
  String? confirmText,
  String? helpText,
2499 2500 2501
  String? errorInvalidText,
  String? hourLabelText,
  String? minuteLabelText,
2502
  RouteSettings? routeSettings,
2503
  EntryModeChangeCallback? onEntryModeChanged,
2504
  Offset? anchorPoint,
2505
}) async {
2506
  assert(context != null);
2507
  assert(initialTime != null);
2508
  assert(useRootNavigator != null);
2509
  assert(initialEntryMode != null);
2510
  assert(debugCheckHasMaterialLocalizations(context));
2511

2512
  final Widget dialog = TimePickerDialog(
2513 2514 2515 2516 2517
    initialTime: initialTime,
    initialEntryMode: initialEntryMode,
    cancelText: cancelText,
    confirmText: confirmText,
    helpText: helpText,
2518 2519 2520
    errorInvalidText: errorInvalidText,
    hourLabelText: hourLabelText,
    minuteLabelText: minuteLabelText,
2521
    onEntryModeChanged: onEntryModeChanged,
2522
  );
2523
  return showDialog<TimeOfDay>(
2524
    context: context,
2525
    useRootNavigator: useRootNavigator,
2526 2527 2528
    builder: (BuildContext context) {
      return builder == null ? dialog : builder(context, dialog);
    },
2529
    routeSettings: routeSettings,
2530
    anchorPoint: anchorPoint,
2531
  );
2532
}
2533 2534

void _announceToAccessibility(BuildContext context, String message) {
2535
  SemanticsService.announce(message, Directionality.of(context));
2536
}