1// Copyright 2015 The Chromium Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5import 'dart:async'; 6import 'dart:math' as math; 7 8import 'package:flutter/rendering.dart'; 9import 'package:flutter/services.dart'; 10import 'package:flutter/widgets.dart'; 11 12import 'button_bar.dart'; 13import 'button_theme.dart'; 14import 'colors.dart'; 15import 'debug.dart'; 16import 'dialog.dart'; 17import 'feedback.dart'; 18import 'flat_button.dart'; 19import 'ink_well.dart'; 20import 'material.dart'; 21import 'material_localizations.dart'; 22import 'text_theme.dart'; 23import 'theme.dart'; 24import 'theme_data.dart'; 25import 'time.dart'; 26 27// Examples can assume: 28// BuildContext context; 29 30const Duration _kDialAnimateDuration = Duration(milliseconds: 200); 31const double _kTwoPi = 2 * math.pi; 32const Duration _kVibrateCommitDelay = Duration(milliseconds: 100); 33 34enum _TimePickerMode { hour, minute } 35 36const double _kTimePickerHeaderPortraitHeight = 96.0; 37const double _kTimePickerHeaderLandscapeWidth = 168.0; 38 39 40const double _kTimePickerWidthPortrait = 328.0; 41const double _kTimePickerWidthLandscape = 512.0; 42 43const double _kTimePickerHeightPortrait = 496.0; 44const double _kTimePickerHeightLandscape = 316.0; 45 46const double _kTimePickerHeightPortraitCollapsed = 484.0; 47const double _kTimePickerHeightLandscapeCollapsed = 304.0; 48 49const BoxConstraints _kMinTappableRegion = BoxConstraints(minWidth: 48, minHeight: 48); 50 51enum _TimePickerHeaderId { 52 hour, 53 colon, 54 minute, 55 period, // AM/PM picker 56 dot, 57 hString, // French Canadian "h" literal 58} 59 60/// Provides properties for rendering time picker header fragments. 61@immutable 62class _TimePickerFragmentContext { 63 const _TimePickerFragmentContext({ 64 @required this.headerTextTheme, 65 @required this.textDirection, 66 @required this.selectedTime, 67 @required this.mode, 68 @required this.activeColor, 69 @required this.activeStyle, 70 @required this.inactiveColor, 71 @required this.inactiveStyle, 72 @required this.onTimeChange, 73 @required this.onModeChange, 74 @required this.targetPlatform, 75 @required this.use24HourDials, 76 }) : assert(headerTextTheme != null), 77 assert(textDirection != null), 78 assert(selectedTime != null), 79 assert(mode != null), 80 assert(activeColor != null), 81 assert(activeStyle != null), 82 assert(inactiveColor != null), 83 assert(inactiveStyle != null), 84 assert(onTimeChange != null), 85 assert(onModeChange != null), 86 assert(targetPlatform != null), 87 assert(use24HourDials != null); 88 89 final TextTheme headerTextTheme; 90 final TextDirection textDirection; 91 final TimeOfDay selectedTime; 92 final _TimePickerMode mode; 93 final Color activeColor; 94 final TextStyle activeStyle; 95 final Color inactiveColor; 96 final TextStyle inactiveStyle; 97 final ValueChanged<TimeOfDay> onTimeChange; 98 final ValueChanged<_TimePickerMode> onModeChange; 99 final TargetPlatform targetPlatform; 100 final bool use24HourDials; 101} 102 103/// Contains the [widget] and layout properties of an atom of time information, 104/// such as am/pm indicator, hour, minute and string literals appearing in the 105/// formatted time string. 106class _TimePickerHeaderFragment { 107 const _TimePickerHeaderFragment({ 108 @required this.layoutId, 109 @required this.widget, 110 this.startMargin = 0.0, 111 }) : assert(layoutId != null), 112 assert(widget != null), 113 assert(startMargin != null); 114 115 /// Identifier used by the custom layout to refer to the widget. 116 final _TimePickerHeaderId layoutId; 117 118 /// The widget that renders a piece of time information. 119 final Widget widget; 120 121 /// Horizontal distance from the fragment appearing at the start of this 122 /// fragment. 123 /// 124 /// This value contributes to the total horizontal width of all fragments 125 /// appearing on the same line, unless it is the first fragment on the line, 126 /// in which case this value is ignored. 127 final double startMargin; 128} 129 130/// An unbreakable part of the time picker header. 131/// 132/// When the picker is laid out vertically, [fragments] of the piece are laid 133/// out on the same line, with each piece getting its own line. 134class _TimePickerHeaderPiece { 135 /// Creates a time picker header piece. 136 /// 137 /// All arguments must be non-null. If the piece does not contain a pivot 138 /// fragment, use the value -1 as a convention. 139 const _TimePickerHeaderPiece(this.pivotIndex, this.fragments, { this.bottomMargin = 0.0 }) 140 : assert(pivotIndex != null), 141 assert(fragments != null), 142 assert(bottomMargin != null); 143 144 /// Index into the [fragments] list, pointing at the fragment that's centered 145 /// horizontally. 146 final int pivotIndex; 147 148 /// Fragments this piece is made of. 149 final List<_TimePickerHeaderFragment> fragments; 150 151 /// Vertical distance between this piece and the next piece. 152 /// 153 /// This property applies only when the header is laid out vertically. 154 final double bottomMargin; 155} 156 157/// Describes how the time picker header must be formatted. 158/// 159/// A [_TimePickerHeaderFormat] is made of multiple [_TimePickerHeaderPiece]s. 160/// A piece is made of multiple [_TimePickerHeaderFragment]s. A fragment has a 161/// widget used to render some time information and contains some layout 162/// properties. 163/// 164/// ## Layout rules 165/// 166/// Pieces are laid out such that all fragments inside the same piece are laid 167/// out horizontally. Pieces are laid out horizontally if portrait orientation, 168/// and vertically in landscape orientation. 169/// 170/// One of the pieces is identified as a _centerpiece_. It is a piece that is 171/// positioned in the center of the header, with all other pieces positioned 172/// to the left or right of it. 173class _TimePickerHeaderFormat { 174 const _TimePickerHeaderFormat(this.centerpieceIndex, this.pieces) 175 : assert(centerpieceIndex != null), 176 assert(pieces != null); 177 178 /// Index into the [pieces] list pointing at the piece that contains the 179 /// pivot fragment. 180 final int centerpieceIndex; 181 182 /// Pieces that constitute a time picker header. 183 final List<_TimePickerHeaderPiece> pieces; 184} 185 186/// Displays the am/pm fragment and provides controls for switching between am 187/// and pm. 188class _DayPeriodControl extends StatelessWidget { 189 const _DayPeriodControl({ 190 @required this.fragmentContext, 191 @required this.orientation, 192 }); 193 194 final _TimePickerFragmentContext fragmentContext; 195 final Orientation orientation; 196 197 void _togglePeriod() { 198 final int newHour = (fragmentContext.selectedTime.hour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay; 199 final TimeOfDay newTime = fragmentContext.selectedTime.replacing(hour: newHour); 200 fragmentContext.onTimeChange(newTime); 201 } 202 203 void _setAm(BuildContext context) { 204 if (fragmentContext.selectedTime.period == DayPeriod.am) { 205 return; 206 } 207 if (fragmentContext.targetPlatform == TargetPlatform.android) { 208 _announceToAccessibility(context, MaterialLocalizations.of(context).anteMeridiemAbbreviation); 209 } 210 _togglePeriod(); 211 } 212 213 void _setPm(BuildContext context) { 214 if (fragmentContext.selectedTime.period == DayPeriod.pm) { 215 return; 216 } 217 if (fragmentContext.targetPlatform == TargetPlatform.android) { 218 _announceToAccessibility(context, MaterialLocalizations.of(context).postMeridiemAbbreviation); 219 } 220 _togglePeriod(); 221 } 222 223 @override 224 Widget build(BuildContext context) { 225 final MaterialLocalizations materialLocalizations = MaterialLocalizations.of(context); 226 final TextTheme headerTextTheme = fragmentContext.headerTextTheme; 227 final TimeOfDay selectedTime = fragmentContext.selectedTime; 228 final Color activeColor = fragmentContext.activeColor; 229 final Color inactiveColor = fragmentContext.inactiveColor; 230 final bool amSelected = selectedTime.period == DayPeriod.am; 231 final TextStyle amStyle = headerTextTheme.subhead.copyWith( 232 color: amSelected ? activeColor: inactiveColor 233 ); 234 final TextStyle pmStyle = headerTextTheme.subhead.copyWith( 235 color: !amSelected ? activeColor: inactiveColor 236 ); 237 final bool layoutPortrait = orientation == Orientation.portrait; 238 239 final Widget amButton = ConstrainedBox( 240 constraints: _kMinTappableRegion, 241 child: Material( 242 type: MaterialType.transparency, 243 child: InkWell( 244 onTap: Feedback.wrapForTap(() => _setAm(context), context), 245 child: Padding( 246 padding: layoutPortrait ? const EdgeInsets.only(bottom: 2.0) : const EdgeInsets.only(right: 4.0), 247 child: Align( 248 alignment: layoutPortrait ? Alignment.bottomCenter : Alignment.centerRight, 249 widthFactor: 1, 250 heightFactor: 1, 251 child: Semantics( 252 selected: amSelected, 253 child: Text(materialLocalizations.anteMeridiemAbbreviation, style: amStyle) 254 ), 255 ), 256 ), 257 ), 258 ), 259 ); 260 261 final Widget pmButton = ConstrainedBox( 262 constraints: _kMinTappableRegion, 263 child: Material( 264 type: MaterialType.transparency, 265 textStyle: pmStyle, 266 child: InkWell( 267 onTap: Feedback.wrapForTap(() => _setPm(context), context), 268 child: Padding( 269 padding: layoutPortrait ? const EdgeInsets.only(top: 2.0) : const EdgeInsets.only(left: 4.0), 270 child: Align( 271 alignment: orientation == Orientation.portrait ? Alignment.topCenter : Alignment.centerLeft, 272 widthFactor: 1, 273 heightFactor: 1, 274 child: Semantics( 275 selected: !amSelected, 276 child: Text(materialLocalizations.postMeridiemAbbreviation, style: pmStyle), 277 ), 278 ), 279 ), 280 ), 281 ), 282 ); 283 284 switch (orientation) { 285 case Orientation.portrait: 286 return Column( 287 mainAxisSize: MainAxisSize.min, 288 children: <Widget>[ 289 amButton, 290 pmButton, 291 ], 292 ); 293 294 case Orientation.landscape: 295 return Row( 296 mainAxisSize: MainAxisSize.min, 297 children: <Widget>[ 298 amButton, 299 pmButton, 300 ], 301 ); 302 } 303 return null; 304 } 305} 306 307/// Displays the hour fragment. 308/// 309/// When tapped changes time picker dial mode to [_TimePickerMode.hour]. 310class _HourControl extends StatelessWidget { 311 const _HourControl({ 312 @required this.fragmentContext, 313 }); 314 315 final _TimePickerFragmentContext fragmentContext; 316 317 @override 318 Widget build(BuildContext context) { 319 assert(debugCheckHasMediaQuery(context)); 320 final bool alwaysUse24HourFormat = MediaQuery.of(context).alwaysUse24HourFormat; 321 final MaterialLocalizations localizations = MaterialLocalizations.of(context); 322 final TextStyle hourStyle = fragmentContext.mode == _TimePickerMode.hour 323 ? fragmentContext.activeStyle 324 : fragmentContext.inactiveStyle; 325 final String formattedHour = localizations.formatHour( 326 fragmentContext.selectedTime, 327 alwaysUse24HourFormat: alwaysUse24HourFormat, 328 ); 329 330 TimeOfDay hoursFromSelected(int hoursToAdd) { 331 if (fragmentContext.use24HourDials) { 332 final int selectedHour = fragmentContext.selectedTime.hour; 333 return fragmentContext.selectedTime.replacing( 334 hour: (selectedHour + hoursToAdd) % TimeOfDay.hoursPerDay, 335 ); 336 } else { 337 // Cycle 1 through 12 without changing day period. 338 final int periodOffset = fragmentContext.selectedTime.periodOffset; 339 final int hours = fragmentContext.selectedTime.hourOfPeriod; 340 return fragmentContext.selectedTime.replacing( 341 hour: periodOffset + (hours + hoursToAdd) % TimeOfDay.hoursPerPeriod, 342 ); 343 } 344 } 345 346 final TimeOfDay nextHour = hoursFromSelected(1); 347 final String formattedNextHour = localizations.formatHour( 348 nextHour, 349 alwaysUse24HourFormat: alwaysUse24HourFormat, 350 ); 351 final TimeOfDay previousHour = hoursFromSelected(-1); 352 final String formattedPreviousHour = localizations.formatHour( 353 previousHour, 354 alwaysUse24HourFormat: alwaysUse24HourFormat, 355 ); 356 357 return Semantics( 358 hint: localizations.timePickerHourModeAnnouncement, 359 value: formattedHour, 360 excludeSemantics: true, 361 increasedValue: formattedNextHour, 362 onIncrease: () { 363 fragmentContext.onTimeChange(nextHour); 364 }, 365 decreasedValue: formattedPreviousHour, 366 onDecrease: () { 367 fragmentContext.onTimeChange(previousHour); 368 }, 369 child: ConstrainedBox( 370 constraints: _kMinTappableRegion, 371 child: Material( 372 type: MaterialType.transparency, 373 child: InkWell( 374 onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.hour), context), 375 child: Text(formattedHour, style: hourStyle, textAlign: TextAlign.end), 376 ), 377 ), 378 ), 379 ); 380 } 381} 382 383/// A passive fragment showing a string value. 384class _StringFragment extends StatelessWidget { 385 const _StringFragment({ 386 @required this.fragmentContext, 387 @required this.value, 388 }); 389 390 final _TimePickerFragmentContext fragmentContext; 391 final String value; 392 393 @override 394 Widget build(BuildContext context) { 395 return ExcludeSemantics( 396 child: Text(value, style: fragmentContext.inactiveStyle), 397 ); 398 } 399} 400 401/// Displays the minute fragment. 402/// 403/// When tapped changes time picker dial mode to [_TimePickerMode.minute]. 404class _MinuteControl extends StatelessWidget { 405 const _MinuteControl({ 406 @required this.fragmentContext, 407 }); 408 409 final _TimePickerFragmentContext fragmentContext; 410 411 @override 412 Widget build(BuildContext context) { 413 final MaterialLocalizations localizations = MaterialLocalizations.of(context); 414 final TextStyle minuteStyle = fragmentContext.mode == _TimePickerMode.minute 415 ? fragmentContext.activeStyle 416 : fragmentContext.inactiveStyle; 417 final String formattedMinute = localizations.formatMinute(fragmentContext.selectedTime); 418 final TimeOfDay nextMinute = fragmentContext.selectedTime.replacing( 419 minute: (fragmentContext.selectedTime.minute + 1) % TimeOfDay.minutesPerHour, 420 ); 421 final String formattedNextMinute = localizations.formatMinute(nextMinute); 422 final TimeOfDay previousMinute = fragmentContext.selectedTime.replacing( 423 minute: (fragmentContext.selectedTime.minute - 1) % TimeOfDay.minutesPerHour, 424 ); 425 final String formattedPreviousMinute = localizations.formatMinute(previousMinute); 426 427 return Semantics( 428 excludeSemantics: true, 429 hint: localizations.timePickerMinuteModeAnnouncement, 430 value: formattedMinute, 431 increasedValue: formattedNextMinute, 432 onIncrease: () { 433 fragmentContext.onTimeChange(nextMinute); 434 }, 435 decreasedValue: formattedPreviousMinute, 436 onDecrease: () { 437 fragmentContext.onTimeChange(previousMinute); 438 }, 439 child: ConstrainedBox( 440 constraints: _kMinTappableRegion, 441 child: Material( 442 type: MaterialType.transparency, 443 child: InkWell( 444 onTap: Feedback.wrapForTap(() => fragmentContext.onModeChange(_TimePickerMode.minute), context), 445 child: Text(formattedMinute, style: minuteStyle, textAlign: TextAlign.start), 446 ), 447 ), 448 ), 449 ); 450 } 451} 452 453/// Provides time picker header layout configuration for the given 454/// [timeOfDayFormat] passing [context] to each widget in the 455/// configuration. 456/// 457/// The [timeOfDayFormat] and [context] arguments must not be null. 458_TimePickerHeaderFormat _buildHeaderFormat( 459 TimeOfDayFormat timeOfDayFormat, 460 _TimePickerFragmentContext context, 461 Orientation orientation 462) { 463 464 // Creates an hour fragment. 465 _TimePickerHeaderFragment hour() { 466 return _TimePickerHeaderFragment( 467 layoutId: _TimePickerHeaderId.hour, 468 widget: _HourControl(fragmentContext: context), 469 ); 470 } 471 472 // Creates a minute fragment. 473 _TimePickerHeaderFragment minute() { 474 return _TimePickerHeaderFragment( 475 layoutId: _TimePickerHeaderId.minute, 476 widget: _MinuteControl(fragmentContext: context), 477 ); 478 } 479 480 // Creates a string fragment. 481 _TimePickerHeaderFragment string(_TimePickerHeaderId layoutId, String value) { 482 return _TimePickerHeaderFragment( 483 layoutId: layoutId, 484 widget: _StringFragment( 485 fragmentContext: context, 486 value: value, 487 ), 488 ); 489 } 490 491 // Creates an am/pm fragment. 492 _TimePickerHeaderFragment dayPeriod() { 493 return _TimePickerHeaderFragment( 494 layoutId: _TimePickerHeaderId.period, 495 widget: _DayPeriodControl(fragmentContext: context, orientation: orientation), 496 ); 497 } 498 499 // Convenience function for creating a time header format with up to two pieces. 500 _TimePickerHeaderFormat format( 501 _TimePickerHeaderPiece piece1, [ 502 _TimePickerHeaderPiece piece2, 503 ]) { 504 final List<_TimePickerHeaderPiece> pieces = <_TimePickerHeaderPiece>[]; 505 switch (context.textDirection) { 506 case TextDirection.ltr: 507 pieces.add(piece1); 508 if (piece2 != null) 509 pieces.add(piece2); 510 break; 511 case TextDirection.rtl: 512 if (piece2 != null) 513 pieces.add(piece2); 514 pieces.add(piece1); 515 break; 516 } 517 int centerpieceIndex; 518 for (int i = 0; i < pieces.length; i += 1) { 519 if (pieces[i].pivotIndex >= 0) { 520 centerpieceIndex = i; 521 } 522 } 523 assert(centerpieceIndex != null); 524 return _TimePickerHeaderFormat(centerpieceIndex, pieces); 525 } 526 527 // Convenience function for creating a time header piece with up to three fragments. 528 _TimePickerHeaderPiece piece({ 529 int pivotIndex = -1, 530 double bottomMargin = 0.0, 531 _TimePickerHeaderFragment fragment1, 532 _TimePickerHeaderFragment fragment2, 533 _TimePickerHeaderFragment fragment3, 534 }) { 535 final List<_TimePickerHeaderFragment> fragments = <_TimePickerHeaderFragment>[fragment1]; 536 if (fragment2 != null) { 537 fragments.add(fragment2); 538 if (fragment3 != null) 539 fragments.add(fragment3); 540 } 541 return _TimePickerHeaderPiece(pivotIndex, fragments, bottomMargin: bottomMargin); 542 } 543 544 switch (timeOfDayFormat) { 545 case TimeOfDayFormat.h_colon_mm_space_a: 546 return format( 547 piece( 548 pivotIndex: 1, 549 fragment1: hour(), 550 fragment2: string(_TimePickerHeaderId.colon, ':'), 551 fragment3: minute(), 552 ), 553 piece( 554 fragment1: dayPeriod(), 555 ), 556 ); 557 case TimeOfDayFormat.H_colon_mm: 558 return format(piece( 559 pivotIndex: 1, 560 fragment1: hour(), 561 fragment2: string(_TimePickerHeaderId.colon, ':'), 562 fragment3: minute(), 563 )); 564 case TimeOfDayFormat.HH_dot_mm: 565 return format(piece( 566 pivotIndex: 1, 567 fragment1: hour(), 568 fragment2: string(_TimePickerHeaderId.dot, '.'), 569 fragment3: minute(), 570 )); 571 case TimeOfDayFormat.a_space_h_colon_mm: 572 return format( 573 piece( 574 fragment1: dayPeriod(), 575 ), 576 piece( 577 pivotIndex: 1, 578 fragment1: hour(), 579 fragment2: string(_TimePickerHeaderId.colon, ':'), 580 fragment3: minute(), 581 ), 582 ); 583 case TimeOfDayFormat.frenchCanadian: 584 return format(piece( 585 pivotIndex: 1, 586 fragment1: hour(), 587 fragment2: string(_TimePickerHeaderId.hString, 'h'), 588 fragment3: minute(), 589 )); 590 case TimeOfDayFormat.HH_colon_mm: 591 return format(piece( 592 pivotIndex: 1, 593 fragment1: hour(), 594 fragment2: string(_TimePickerHeaderId.colon, ':'), 595 fragment3: minute(), 596 )); 597 } 598 599 return null; 600} 601 602class _TimePickerHeaderLayout extends MultiChildLayoutDelegate { 603 _TimePickerHeaderLayout(this.orientation, this.format) 604 : assert(orientation != null), 605 assert(format != null); 606 607 final Orientation orientation; 608 final _TimePickerHeaderFormat format; 609 610 @override 611 void performLayout(Size size) { 612 final BoxConstraints constraints = BoxConstraints.loose(size); 613 614 switch (orientation) { 615 case Orientation.portrait: 616 _layoutHorizontally(size, constraints); 617 break; 618 case Orientation.landscape: 619 _layoutVertically(size, constraints); 620 break; 621 } 622 } 623 624 void _layoutHorizontally(Size size, BoxConstraints constraints) { 625 final List<_TimePickerHeaderFragment> fragmentsFlattened = <_TimePickerHeaderFragment>[]; 626 final Map<_TimePickerHeaderId, Size> childSizes = <_TimePickerHeaderId, Size>{}; 627 int pivotIndex = 0; 628 for (int pieceIndex = 0; pieceIndex < format.pieces.length; pieceIndex += 1) { 629 final _TimePickerHeaderPiece piece = format.pieces[pieceIndex]; 630 for (final _TimePickerHeaderFragment fragment in piece.fragments) { 631 childSizes[fragment.layoutId] = layoutChild(fragment.layoutId, constraints); 632 fragmentsFlattened.add(fragment); 633 } 634 635 if (pieceIndex == format.centerpieceIndex) 636 pivotIndex += format.pieces[format.centerpieceIndex].pivotIndex; 637 else if (pieceIndex < format.centerpieceIndex) 638 pivotIndex += piece.fragments.length; 639 } 640 641 _positionPivoted(size.width, size.height / 2.0, childSizes, fragmentsFlattened, pivotIndex); 642 } 643 644 void _layoutVertically(Size size, BoxConstraints constraints) { 645 final Map<_TimePickerHeaderId, Size> childSizes = <_TimePickerHeaderId, Size>{}; 646 final List<double> pieceHeights = <double>[]; 647 double height = 0.0; 648 double margin = 0.0; 649 for (final _TimePickerHeaderPiece piece in format.pieces) { 650 double pieceHeight = 0.0; 651 for (final _TimePickerHeaderFragment fragment in piece.fragments) { 652 final Size childSize = childSizes[fragment.layoutId] = layoutChild(fragment.layoutId, constraints); 653 pieceHeight = math.max(pieceHeight, childSize.height); 654 } 655 pieceHeights.add(pieceHeight); 656 height += pieceHeight + margin; 657 // Delay application of margin until next piece because margin of the 658 // bottom-most piece should not contribute to the size. 659 margin = piece.bottomMargin; 660 } 661 662 final _TimePickerHeaderPiece centerpiece = format.pieces[format.centerpieceIndex]; 663 double y = (size.height - height) / 2.0; 664 for (int pieceIndex = 0; pieceIndex < format.pieces.length; pieceIndex += 1) { 665 final double pieceVerticalCenter = y + pieceHeights[pieceIndex] / 2.0; 666 if (pieceIndex != format.centerpieceIndex) 667 _positionPiece(size.width, pieceVerticalCenter, childSizes, format.pieces[pieceIndex].fragments); 668 else 669 _positionPivoted(size.width, pieceVerticalCenter, childSizes, centerpiece.fragments, centerpiece.pivotIndex); 670 671 y += pieceHeights[pieceIndex] + format.pieces[pieceIndex].bottomMargin; 672 } 673 } 674 675 void _positionPivoted(double width, double y, Map<_TimePickerHeaderId, Size> childSizes, List<_TimePickerHeaderFragment> fragments, int pivotIndex) { 676 double tailWidth = childSizes[fragments[pivotIndex].layoutId].width / 2.0; 677 for (_TimePickerHeaderFragment fragment in fragments.skip(pivotIndex + 1)) { 678 tailWidth += childSizes[fragment.layoutId].width + fragment.startMargin; 679 } 680 681 double x = width / 2.0 + tailWidth; 682 x = math.min(x, width); 683 for (int i = fragments.length - 1; i >= 0; i -= 1) { 684 final _TimePickerHeaderFragment fragment = fragments[i]; 685 final Size childSize = childSizes[fragment.layoutId]; 686 x -= childSize.width; 687 positionChild(fragment.layoutId, Offset(x, y - childSize.height / 2.0)); 688 x -= fragment.startMargin; 689 } 690 } 691 692 void _positionPiece(double width, double centeredAroundY, Map<_TimePickerHeaderId, Size> childSizes, List<_TimePickerHeaderFragment> fragments) { 693 double pieceWidth = 0.0; 694 double nextMargin = 0.0; 695 for (_TimePickerHeaderFragment fragment in fragments) { 696 final Size childSize = childSizes[fragment.layoutId]; 697 pieceWidth += childSize.width + nextMargin; 698 // Delay application of margin until next element because margin of the 699 // left-most fragment should not contribute to the size. 700 nextMargin = fragment.startMargin; 701 } 702 double x = (width + pieceWidth) / 2.0; 703 for (int i = fragments.length - 1; i >= 0; i -= 1) { 704 final _TimePickerHeaderFragment fragment = fragments[i]; 705 final Size childSize = childSizes[fragment.layoutId]; 706 x -= childSize.width; 707 positionChild(fragment.layoutId, Offset(x, centeredAroundY - childSize.height / 2.0)); 708 x -= fragment.startMargin; 709 } 710 } 711 712 @override 713 bool shouldRelayout(_TimePickerHeaderLayout oldDelegate) => orientation != oldDelegate.orientation || format != oldDelegate.format; 714} 715 716class _TimePickerHeader extends StatelessWidget { 717 const _TimePickerHeader({ 718 @required this.selectedTime, 719 @required this.mode, 720 @required this.orientation, 721 @required this.onModeChanged, 722 @required this.onChanged, 723 @required this.use24HourDials, 724 }) : assert(selectedTime != null), 725 assert(mode != null), 726 assert(orientation != null), 727 assert(use24HourDials != null); 728 729 final TimeOfDay selectedTime; 730 final _TimePickerMode mode; 731 final Orientation orientation; 732 final ValueChanged<_TimePickerMode> onModeChanged; 733 final ValueChanged<TimeOfDay> onChanged; 734 final bool use24HourDials; 735 736 void _handleChangeMode(_TimePickerMode value) { 737 if (value != mode) 738 onModeChanged(value); 739 } 740 741 TextStyle _getBaseHeaderStyle(TextTheme headerTextTheme) { 742 // These font sizes aren't listed in the spec explicitly. I worked them out 743 // by measuring the text using a screen ruler and comparing them to the 744 // screen shots of the time picker in the spec. 745 assert(orientation != null); 746 switch (orientation) { 747 case Orientation.portrait: 748 return headerTextTheme.display3.copyWith(fontSize: 60.0); 749 case Orientation.landscape: 750 return headerTextTheme.display2.copyWith(fontSize: 50.0); 751 } 752 return null; 753 } 754 755 @override 756 Widget build(BuildContext context) { 757 assert(debugCheckHasMediaQuery(context)); 758 final ThemeData themeData = Theme.of(context); 759 final MediaQueryData media = MediaQuery.of(context); 760 final TimeOfDayFormat timeOfDayFormat = MaterialLocalizations.of(context) 761 .timeOfDayFormat(alwaysUse24HourFormat: media.alwaysUse24HourFormat); 762 763 EdgeInsets padding; 764 double height; 765 double width; 766 767 assert(orientation != null); 768 switch (orientation) { 769 case Orientation.portrait: 770 height = _kTimePickerHeaderPortraitHeight; 771 padding = const EdgeInsets.symmetric(horizontal: 24.0); 772 break; 773 case Orientation.landscape: 774 width = _kTimePickerHeaderLandscapeWidth; 775 padding = const EdgeInsets.symmetric(horizontal: 16.0); 776 break; 777 } 778 779 Color backgroundColor; 780 switch (themeData.brightness) { 781 case Brightness.light: 782 backgroundColor = themeData.primaryColor; 783 break; 784 case Brightness.dark: 785 backgroundColor = themeData.backgroundColor; 786 break; 787 } 788 789 Color activeColor; 790 Color inactiveColor; 791 switch (themeData.primaryColorBrightness) { 792 case Brightness.light: 793 activeColor = Colors.black87; 794 inactiveColor = Colors.black54; 795 break; 796 case Brightness.dark: 797 activeColor = Colors.white; 798 inactiveColor = Colors.white70; 799 break; 800 } 801 802 final TextTheme headerTextTheme = themeData.primaryTextTheme; 803 final TextStyle baseHeaderStyle = _getBaseHeaderStyle(headerTextTheme); 804 final _TimePickerFragmentContext fragmentContext = _TimePickerFragmentContext( 805 headerTextTheme: headerTextTheme, 806 textDirection: Directionality.of(context), 807 selectedTime: selectedTime, 808 mode: mode, 809 activeColor: activeColor, 810 activeStyle: baseHeaderStyle.copyWith(color: activeColor), 811 inactiveColor: inactiveColor, 812 inactiveStyle: baseHeaderStyle.copyWith(color: inactiveColor), 813 onTimeChange: onChanged, 814 onModeChange: _handleChangeMode, 815 targetPlatform: themeData.platform, 816 use24HourDials: use24HourDials, 817 ); 818 819 final _TimePickerHeaderFormat format = _buildHeaderFormat(timeOfDayFormat, fragmentContext, orientation); 820 821 return Container( 822 width: width, 823 height: height, 824 padding: padding, 825 color: backgroundColor, 826 child: CustomMultiChildLayout( 827 delegate: _TimePickerHeaderLayout(orientation, format), 828 children: format.pieces 829 .expand<_TimePickerHeaderFragment>((_TimePickerHeaderPiece piece) => piece.fragments) 830 .map<Widget>((_TimePickerHeaderFragment fragment) { 831 return LayoutId( 832 id: fragment.layoutId, 833 child: fragment.widget, 834 ); 835 }) 836 .toList(), 837 ), 838 ); 839 } 840} 841 842enum _DialRing { 843 outer, 844 inner, 845} 846 847class _TappableLabel { 848 _TappableLabel({ 849 @required this.value, 850 @required this.painter, 851 @required this.onTap, 852 }); 853 854 /// The value this label is displaying. 855 final int value; 856 857 /// Paints the text of the label. 858 final TextPainter painter; 859 860 /// Called when a tap gesture is detected on the label. 861 final VoidCallback onTap; 862} 863 864class _DialPainter extends CustomPainter { 865 const _DialPainter({ 866 @required this.primaryOuterLabels, 867 @required this.primaryInnerLabels, 868 @required this.secondaryOuterLabels, 869 @required this.secondaryInnerLabels, 870 @required this.backgroundColor, 871 @required this.accentColor, 872 @required this.theta, 873 @required this.activeRing, 874 @required this.textDirection, 875 @required this.selectedValue, 876 }); 877 878 final List<_TappableLabel> primaryOuterLabels; 879 final List<_TappableLabel> primaryInnerLabels; 880 final List<_TappableLabel> secondaryOuterLabels; 881 final List<_TappableLabel> secondaryInnerLabels; 882 final Color backgroundColor; 883 final Color accentColor; 884 final double theta; 885 final _DialRing activeRing; 886 final TextDirection textDirection; 887 final int selectedValue; 888 889 @override 890 void paint(Canvas canvas, Size size) { 891 final double radius = size.shortestSide / 2.0; 892 final Offset center = Offset(size.width / 2.0, size.height / 2.0); 893 final Offset centerPoint = center; 894 canvas.drawCircle(centerPoint, radius, Paint()..color = backgroundColor); 895 896 const double labelPadding = 24.0; 897 final double outerLabelRadius = radius - labelPadding; 898 final double innerLabelRadius = radius - labelPadding * 2.5; 899 Offset getOffsetForTheta(double theta, _DialRing ring) { 900 double labelRadius; 901 switch (ring) { 902 case _DialRing.outer: 903 labelRadius = outerLabelRadius; 904 break; 905 case _DialRing.inner: 906 labelRadius = innerLabelRadius; 907 break; 908 } 909 return center + Offset(labelRadius * math.cos(theta), 910 -labelRadius * math.sin(theta)); 911 } 912 913 void paintLabels(List<_TappableLabel> labels, _DialRing ring) { 914 if (labels == null) 915 return; 916 final double labelThetaIncrement = -_kTwoPi / labels.length; 917 double labelTheta = math.pi / 2.0; 918 919 for (_TappableLabel label in labels) { 920 final TextPainter labelPainter = label.painter; 921 final Offset labelOffset = Offset(-labelPainter.width / 2.0, -labelPainter.height / 2.0); 922 labelPainter.paint(canvas, getOffsetForTheta(labelTheta, ring) + labelOffset); 923 labelTheta += labelThetaIncrement; 924 } 925 } 926 927 paintLabels(primaryOuterLabels, _DialRing.outer); 928 paintLabels(primaryInnerLabels, _DialRing.inner); 929 930 final Paint selectorPaint = Paint() 931 ..color = accentColor; 932 final Offset focusedPoint = getOffsetForTheta(theta, activeRing); 933 const double focusedRadius = labelPadding - 4.0; 934 canvas.drawCircle(centerPoint, 4.0, selectorPaint); 935 canvas.drawCircle(focusedPoint, focusedRadius, selectorPaint); 936 selectorPaint.strokeWidth = 2.0; 937 canvas.drawLine(centerPoint, focusedPoint, selectorPaint); 938 939 final Rect focusedRect = Rect.fromCircle( 940 center: focusedPoint, radius: focusedRadius, 941 ); 942 canvas 943 ..save() 944 ..clipPath(Path()..addOval(focusedRect)); 945 paintLabels(secondaryOuterLabels, _DialRing.outer); 946 paintLabels(secondaryInnerLabels, _DialRing.inner); 947 canvas.restore(); 948 } 949 950 static const double _semanticNodeSizeScale = 1.5; 951 952 @override 953 SemanticsBuilderCallback get semanticsBuilder => _buildSemantics; 954 955 /// Creates semantics nodes for the hour/minute labels painted on the dial. 956 /// 957 /// The nodes are positioned on top of the text and their size is 958 /// [_semanticNodeSizeScale] bigger than those of the text boxes to provide 959 /// bigger tap area. 960 List<CustomPainterSemantics> _buildSemantics(Size size) { 961 final double radius = size.shortestSide / 2.0; 962 final Offset center = Offset(size.width / 2.0, size.height / 2.0); 963 const double labelPadding = 24.0; 964 final double outerLabelRadius = radius - labelPadding; 965 final double innerLabelRadius = radius - labelPadding * 2.5; 966 967 Offset getOffsetForTheta(double theta, _DialRing ring) { 968 double labelRadius; 969 switch (ring) { 970 case _DialRing.outer: 971 labelRadius = outerLabelRadius; 972 break; 973 case _DialRing.inner: 974 labelRadius = innerLabelRadius; 975 break; 976 } 977 return center + Offset(labelRadius * math.cos(theta), 978 -labelRadius * math.sin(theta)); 979 } 980 981 final List<CustomPainterSemantics> nodes = <CustomPainterSemantics>[]; 982 983 void paintLabels(List<_TappableLabel> labels, _DialRing ring) { 984 if (labels == null) 985 return; 986 final double labelThetaIncrement = -_kTwoPi / labels.length; 987 final double ordinalOffset = ring == _DialRing.inner ? 12.0 : 0.0; 988 double labelTheta = math.pi / 2.0; 989 990 for (int i = 0; i < labels.length; i++) { 991 final _TappableLabel label = labels[i]; 992 final TextPainter labelPainter = label.painter; 993 final double width = labelPainter.width * _semanticNodeSizeScale; 994 final double height = labelPainter.height * _semanticNodeSizeScale; 995 final Offset nodeOffset = getOffsetForTheta(labelTheta, ring) + Offset(-width / 2.0, -height / 2.0); 996 final TextSpan textSpan = labelPainter.text; 997 final CustomPainterSemantics node = CustomPainterSemantics( 998 rect: Rect.fromLTRB( 999 nodeOffset.dx - 24.0 + width / 2, 1000 nodeOffset.dy - 24.0 + height / 2, 1001 nodeOffset.dx + 24.0 + width / 2, 1002 nodeOffset.dy + 24.0 + height / 2, 1003 ), 1004 properties: SemanticsProperties( 1005 sortKey: OrdinalSortKey(i.toDouble() + ordinalOffset), 1006 selected: label.value == selectedValue, 1007 value: textSpan?.text, 1008 textDirection: textDirection, 1009 onTap: label.onTap, 1010 ), 1011 tags: const <SemanticsTag>{ 1012 // Used by tests to find this node. 1013 SemanticsTag('dial-label'), 1014 }, 1015 ); 1016 nodes.add(node); 1017 labelTheta += labelThetaIncrement; 1018 } 1019 } 1020 1021 paintLabels(primaryOuterLabels, _DialRing.outer); 1022 paintLabels(primaryInnerLabels, _DialRing.inner); 1023 1024 return nodes; 1025 } 1026 1027 @override 1028 bool shouldRepaint(_DialPainter oldPainter) { 1029 return oldPainter.primaryOuterLabels != primaryOuterLabels 1030 || oldPainter.primaryInnerLabels != primaryInnerLabels 1031 || oldPainter.secondaryOuterLabels != secondaryOuterLabels 1032 || oldPainter.secondaryInnerLabels != secondaryInnerLabels 1033 || oldPainter.backgroundColor != backgroundColor 1034 || oldPainter.accentColor != accentColor 1035 || oldPainter.theta != theta 1036 || oldPainter.activeRing != activeRing; 1037 } 1038} 1039 1040class _Dial extends StatefulWidget { 1041 const _Dial({ 1042 @required this.selectedTime, 1043 @required this.mode, 1044 @required this.use24HourDials, 1045 @required this.onChanged, 1046 @required this.onHourSelected, 1047 }) : assert(selectedTime != null), 1048 assert(mode != null), 1049 assert(use24HourDials != null); 1050 1051 final TimeOfDay selectedTime; 1052 final _TimePickerMode mode; 1053 final bool use24HourDials; 1054 final ValueChanged<TimeOfDay> onChanged; 1055 final VoidCallback onHourSelected; 1056 1057 @override 1058 _DialState createState() => _DialState(); 1059} 1060 1061class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { 1062 @override 1063 void initState() { 1064 super.initState(); 1065 _updateDialRingFromWidget(); 1066 _thetaController = AnimationController( 1067 duration: _kDialAnimateDuration, 1068 vsync: this, 1069 ); 1070 _thetaTween = Tween<double>(begin: _getThetaForTime(widget.selectedTime)); 1071 _theta = _thetaController 1072 .drive(CurveTween(curve: Curves.fastOutSlowIn)) 1073 .drive(_thetaTween) 1074 ..addListener(() => setState(() { /* _theta.value has changed */ })); 1075 } 1076 1077 ThemeData themeData; 1078 MaterialLocalizations localizations; 1079 MediaQueryData media; 1080 1081 @override 1082 void didChangeDependencies() { 1083 super.didChangeDependencies(); 1084 assert(debugCheckHasMediaQuery(context)); 1085 themeData = Theme.of(context); 1086 localizations = MaterialLocalizations.of(context); 1087 media = MediaQuery.of(context); 1088 } 1089 1090 @override 1091 void didUpdateWidget(_Dial oldWidget) { 1092 super.didUpdateWidget(oldWidget); 1093 _updateDialRingFromWidget(); 1094 if (widget.mode != oldWidget.mode || widget.selectedTime != oldWidget.selectedTime) { 1095 if (!_dragging) 1096 _animateTo(_getThetaForTime(widget.selectedTime)); 1097 } 1098 } 1099 1100 void _updateDialRingFromWidget() { 1101 if (widget.mode == _TimePickerMode.hour && widget.use24HourDials) { 1102 _activeRing = widget.selectedTime.hour >= 1 && widget.selectedTime.hour <= 12 1103 ? _DialRing.inner 1104 : _DialRing.outer; 1105 } else { 1106 _activeRing = _DialRing.outer; 1107 } 1108 } 1109 1110 @override 1111 void dispose() { 1112 _thetaController.dispose(); 1113 super.dispose(); 1114 } 1115 1116 Tween<double> _thetaTween; 1117 Animation<double> _theta; 1118 AnimationController _thetaController; 1119 bool _dragging = false; 1120 1121 static double _nearest(double target, double a, double b) { 1122 return ((target - a).abs() < (target - b).abs()) ? a : b; 1123 } 1124 1125 void _animateTo(double targetTheta) { 1126 final double currentTheta = _theta.value; 1127 double beginTheta = _nearest(targetTheta, currentTheta, currentTheta + _kTwoPi); 1128 beginTheta = _nearest(targetTheta, beginTheta, currentTheta - _kTwoPi); 1129 _thetaTween 1130 ..begin = beginTheta 1131 ..end = targetTheta; 1132 _thetaController 1133 ..value = 0.0 1134 ..forward(); 1135 } 1136 1137 double _getThetaForTime(TimeOfDay time) { 1138 final double fraction = widget.mode == _TimePickerMode.hour 1139 ? (time.hour / TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerPeriod 1140 : (time.minute / TimeOfDay.minutesPerHour) % TimeOfDay.minutesPerHour; 1141 return (math.pi / 2.0 - fraction * _kTwoPi) % _kTwoPi; 1142 } 1143 1144 TimeOfDay _getTimeForTheta(double theta) { 1145 final double fraction = (0.25 - (theta % _kTwoPi) / _kTwoPi) % 1.0; 1146 if (widget.mode == _TimePickerMode.hour) { 1147 int newHour = (fraction * TimeOfDay.hoursPerPeriod).round() % TimeOfDay.hoursPerPeriod; 1148 if (widget.use24HourDials) { 1149 if (_activeRing == _DialRing.outer) { 1150 if (newHour != 0) 1151 newHour = (newHour + TimeOfDay.hoursPerPeriod) % TimeOfDay.hoursPerDay; 1152 } else if (newHour == 0) { 1153 newHour = TimeOfDay.hoursPerPeriod; 1154 } 1155 } else { 1156 newHour = newHour + widget.selectedTime.periodOffset; 1157 } 1158 return widget.selectedTime.replacing(hour: newHour); 1159 } else { 1160 return widget.selectedTime.replacing( 1161 minute: (fraction * TimeOfDay.minutesPerHour).round() % TimeOfDay.minutesPerHour 1162 ); 1163 } 1164 } 1165 1166 TimeOfDay _notifyOnChangedIfNeeded() { 1167 final TimeOfDay current = _getTimeForTheta(_theta.value); 1168 if (widget.onChanged == null) 1169 return current; 1170 if (current != widget.selectedTime) 1171 widget.onChanged(current); 1172 return current; 1173 } 1174 1175 void _updateThetaForPan() { 1176 setState(() { 1177 final Offset offset = _position - _center; 1178 final double angle = (math.atan2(offset.dx, offset.dy) - math.pi / 2.0) % _kTwoPi; 1179 _thetaTween 1180 ..begin = angle 1181 ..end = angle; // The controller doesn't animate during the pan gesture. 1182 final RenderBox box = context.findRenderObject(); 1183 final double radius = box.size.shortestSide / 2.0; 1184 if (widget.mode == _TimePickerMode.hour && widget.use24HourDials) { 1185 if (offset.distance * 1.5 < radius) 1186 _activeRing = _DialRing.inner; 1187 else 1188 _activeRing = _DialRing.outer; 1189 } 1190 }); 1191 } 1192 1193 Offset _position; 1194 Offset _center; 1195 _DialRing _activeRing = _DialRing.outer; 1196 1197 void _handlePanStart(DragStartDetails details) { 1198 assert(!_dragging); 1199 _dragging = true; 1200 final RenderBox box = context.findRenderObject(); 1201 _position = box.globalToLocal(details.globalPosition); 1202 _center = box.size.center(Offset.zero); 1203 _updateThetaForPan(); 1204 _notifyOnChangedIfNeeded(); 1205 } 1206 1207 void _handlePanUpdate(DragUpdateDetails details) { 1208 _position += details.delta; 1209 _updateThetaForPan(); 1210 _notifyOnChangedIfNeeded(); 1211 } 1212 1213 void _handlePanEnd(DragEndDetails details) { 1214 assert(_dragging); 1215 _dragging = false; 1216 _position = null; 1217 _center = null; 1218 _animateTo(_getThetaForTime(widget.selectedTime)); 1219 if (widget.mode == _TimePickerMode.hour) { 1220 if (widget.onHourSelected != null) { 1221 widget.onHourSelected(); 1222 } 1223 } 1224 } 1225 1226 void _handleTapUp(TapUpDetails details) { 1227 final RenderBox box = context.findRenderObject(); 1228 _position = box.globalToLocal(details.globalPosition); 1229 _center = box.size.center(Offset.zero); 1230 _updateThetaForPan(); 1231 final TimeOfDay newTime = _notifyOnChangedIfNeeded(); 1232 if (widget.mode == _TimePickerMode.hour) { 1233 if (widget.use24HourDials) { 1234 _announceToAccessibility(context, localizations.formatDecimal(newTime.hour)); 1235 } else { 1236 _announceToAccessibility(context, localizations.formatDecimal(newTime.hourOfPeriod)); 1237 } 1238 if (widget.onHourSelected != null) { 1239 widget.onHourSelected(); 1240 } 1241 } else { 1242 _announceToAccessibility(context, localizations.formatDecimal(newTime.minute)); 1243 } 1244 _animateTo(_getThetaForTime(_getTimeForTheta(_theta.value))); 1245 _dragging = false; 1246 _position = null; 1247 _center = null; 1248 } 1249 1250 void _selectHour(int hour) { 1251 _announceToAccessibility(context, localizations.formatDecimal(hour)); 1252 TimeOfDay time; 1253 if (widget.mode == _TimePickerMode.hour && widget.use24HourDials) { 1254 _activeRing = hour >= 1 && hour <= 12 1255 ? _DialRing.inner 1256 : _DialRing.outer; 1257 time = TimeOfDay(hour: hour, minute: widget.selectedTime.minute); 1258 } else { 1259 _activeRing = _DialRing.outer; 1260 if (widget.selectedTime.period == DayPeriod.am) { 1261 time = TimeOfDay(hour: hour, minute: widget.selectedTime.minute); 1262 } else { 1263 time = TimeOfDay(hour: hour + TimeOfDay.hoursPerPeriod, minute: widget.selectedTime.minute); 1264 } 1265 } 1266 final double angle = _getThetaForTime(time); 1267 _thetaTween 1268 ..begin = angle 1269 ..end = angle; 1270 _notifyOnChangedIfNeeded(); 1271 } 1272 1273 void _selectMinute(int minute) { 1274 _announceToAccessibility(context, localizations.formatDecimal(minute)); 1275 final TimeOfDay time = TimeOfDay( 1276 hour: widget.selectedTime.hour, 1277 minute: minute, 1278 ); 1279 final double angle = _getThetaForTime(time); 1280 _thetaTween 1281 ..begin = angle 1282 ..end = angle; 1283 _notifyOnChangedIfNeeded(); 1284 } 1285 1286 static const List<TimeOfDay> _amHours = <TimeOfDay>[ 1287 TimeOfDay(hour: 12, minute: 0), 1288 TimeOfDay(hour: 1, minute: 0), 1289 TimeOfDay(hour: 2, minute: 0), 1290 TimeOfDay(hour: 3, minute: 0), 1291 TimeOfDay(hour: 4, minute: 0), 1292 TimeOfDay(hour: 5, minute: 0), 1293 TimeOfDay(hour: 6, minute: 0), 1294 TimeOfDay(hour: 7, minute: 0), 1295 TimeOfDay(hour: 8, minute: 0), 1296 TimeOfDay(hour: 9, minute: 0), 1297 TimeOfDay(hour: 10, minute: 0), 1298 TimeOfDay(hour: 11, minute: 0), 1299 ]; 1300 1301 static const List<TimeOfDay> _pmHours = <TimeOfDay>[ 1302 TimeOfDay(hour: 0, minute: 0), 1303 TimeOfDay(hour: 13, minute: 0), 1304 TimeOfDay(hour: 14, minute: 0), 1305 TimeOfDay(hour: 15, minute: 0), 1306 TimeOfDay(hour: 16, minute: 0), 1307 TimeOfDay(hour: 17, minute: 0), 1308 TimeOfDay(hour: 18, minute: 0), 1309 TimeOfDay(hour: 19, minute: 0), 1310 TimeOfDay(hour: 20, minute: 0), 1311 TimeOfDay(hour: 21, minute: 0), 1312 TimeOfDay(hour: 22, minute: 0), 1313 TimeOfDay(hour: 23, minute: 0), 1314 ]; 1315 1316 _TappableLabel _buildTappableLabel(TextTheme textTheme, int value, String label, VoidCallback onTap) { 1317 final TextStyle style = textTheme.subhead; 1318 // TODO(abarth): Handle textScaleFactor. 1319 // https://github.com/flutter/flutter/issues/5939 1320 return _TappableLabel( 1321 value: value, 1322 painter: TextPainter( 1323 text: TextSpan(style: style, text: label), 1324 textDirection: TextDirection.ltr, 1325 )..layout(), 1326 onTap: onTap, 1327 ); 1328 } 1329 1330 List<_TappableLabel> _build24HourInnerRing(TextTheme textTheme) { 1331 final List<_TappableLabel> labels = <_TappableLabel>[]; 1332 for (TimeOfDay timeOfDay in _amHours) { 1333 labels.add(_buildTappableLabel( 1334 textTheme, 1335 timeOfDay.hour, 1336 localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat), 1337 () { 1338 _selectHour(timeOfDay.hour); 1339 }, 1340 )); 1341 } 1342 return labels; 1343 } 1344 1345 List<_TappableLabel> _build24HourOuterRing(TextTheme textTheme) { 1346 final List<_TappableLabel> labels = <_TappableLabel>[]; 1347 for (TimeOfDay timeOfDay in _pmHours) { 1348 labels.add(_buildTappableLabel( 1349 textTheme, 1350 timeOfDay.hour, 1351 localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat), 1352 () { 1353 _selectHour(timeOfDay.hour); 1354 }, 1355 )); 1356 } 1357 return labels; 1358 } 1359 1360 List<_TappableLabel> _build12HourOuterRing(TextTheme textTheme) { 1361 final List<_TappableLabel> labels = <_TappableLabel>[]; 1362 for (TimeOfDay timeOfDay in _amHours) { 1363 labels.add(_buildTappableLabel( 1364 textTheme, 1365 timeOfDay.hour, 1366 localizations.formatHour(timeOfDay, alwaysUse24HourFormat: media.alwaysUse24HourFormat), 1367 () { 1368 _selectHour(timeOfDay.hour); 1369 }, 1370 )); 1371 } 1372 return labels; 1373 } 1374 1375 List<_TappableLabel> _buildMinutes(TextTheme textTheme) { 1376 const List<TimeOfDay> _minuteMarkerValues = <TimeOfDay>[ 1377 TimeOfDay(hour: 0, minute: 0), 1378 TimeOfDay(hour: 0, minute: 5), 1379 TimeOfDay(hour: 0, minute: 10), 1380 TimeOfDay(hour: 0, minute: 15), 1381 TimeOfDay(hour: 0, minute: 20), 1382 TimeOfDay(hour: 0, minute: 25), 1383 TimeOfDay(hour: 0, minute: 30), 1384 TimeOfDay(hour: 0, minute: 35), 1385 TimeOfDay(hour: 0, minute: 40), 1386 TimeOfDay(hour: 0, minute: 45), 1387 TimeOfDay(hour: 0, minute: 50), 1388 TimeOfDay(hour: 0, minute: 55), 1389 ]; 1390 1391 final List<_TappableLabel> labels = <_TappableLabel>[]; 1392 for (TimeOfDay timeOfDay in _minuteMarkerValues) { 1393 labels.add(_buildTappableLabel( 1394 textTheme, 1395 timeOfDay.minute, 1396 localizations.formatMinute(timeOfDay), 1397 () { 1398 _selectMinute(timeOfDay.minute); 1399 }, 1400 )); 1401 } 1402 return labels; 1403 } 1404 1405 @override 1406 Widget build(BuildContext context) { 1407 Color backgroundColor; 1408 switch (themeData.brightness) { 1409 case Brightness.light: 1410 backgroundColor = Colors.grey[200]; 1411 break; 1412 case Brightness.dark: 1413 backgroundColor = themeData.backgroundColor; 1414 break; 1415 } 1416 1417 final ThemeData theme = Theme.of(context); 1418 List<_TappableLabel> primaryOuterLabels; 1419 List<_TappableLabel> primaryInnerLabels; 1420 List<_TappableLabel> secondaryOuterLabels; 1421 List<_TappableLabel> secondaryInnerLabels; 1422 int selectedDialValue; 1423 switch (widget.mode) { 1424 case _TimePickerMode.hour: 1425 if (widget.use24HourDials) { 1426 selectedDialValue = widget.selectedTime.hour; 1427 primaryOuterLabels = _build24HourOuterRing(theme.textTheme); 1428 secondaryOuterLabels = _build24HourOuterRing(theme.accentTextTheme); 1429 primaryInnerLabels = _build24HourInnerRing(theme.textTheme); 1430 secondaryInnerLabels = _build24HourInnerRing(theme.accentTextTheme); 1431 } else { 1432 selectedDialValue = widget.selectedTime.hourOfPeriod; 1433 primaryOuterLabels = _build12HourOuterRing(theme.textTheme); 1434 secondaryOuterLabels = _build12HourOuterRing(theme.accentTextTheme); 1435 } 1436 break; 1437 case _TimePickerMode.minute: 1438 selectedDialValue = widget.selectedTime.minute; 1439 primaryOuterLabels = _buildMinutes(theme.textTheme); 1440 primaryInnerLabels = null; 1441 secondaryOuterLabels = _buildMinutes(theme.accentTextTheme); 1442 secondaryInnerLabels = null; 1443 break; 1444 } 1445 1446 return GestureDetector( 1447 excludeFromSemantics: true, 1448 onPanStart: _handlePanStart, 1449 onPanUpdate: _handlePanUpdate, 1450 onPanEnd: _handlePanEnd, 1451 onTapUp: _handleTapUp, 1452 child: CustomPaint( 1453 key: const ValueKey<String>('time-picker-dial'), 1454 painter: _DialPainter( 1455 selectedValue: selectedDialValue, 1456 primaryOuterLabels: primaryOuterLabels, 1457 primaryInnerLabels: primaryInnerLabels, 1458 secondaryOuterLabels: secondaryOuterLabels, 1459 secondaryInnerLabels: secondaryInnerLabels, 1460 backgroundColor: backgroundColor, 1461 accentColor: themeData.accentColor, 1462 theta: _theta.value, 1463 activeRing: _activeRing, 1464 textDirection: Directionality.of(context), 1465 ), 1466 ), 1467 ); 1468 } 1469} 1470 1471/// A material design time picker designed to appear inside a popup dialog. 1472/// 1473/// Pass this widget to [showDialog]. The value returned by [showDialog] is the 1474/// selected [TimeOfDay] if the user taps the "OK" button, or null if the user 1475/// taps the "CANCEL" button. The selected time is reported by calling 1476/// [Navigator.pop]. 1477class _TimePickerDialog extends StatefulWidget { 1478 /// Creates a material time picker. 1479 /// 1480 /// [initialTime] must not be null. 1481 const _TimePickerDialog({ 1482 Key key, 1483 @required this.initialTime, 1484 }) : assert(initialTime != null), 1485 super(key: key); 1486 1487 /// The time initially selected when the dialog is shown. 1488 final TimeOfDay initialTime; 1489 1490 @override 1491 _TimePickerDialogState createState() => _TimePickerDialogState(); 1492} 1493 1494class _TimePickerDialogState extends State<_TimePickerDialog> { 1495 @override 1496 void initState() { 1497 super.initState(); 1498 _selectedTime = widget.initialTime; 1499 } 1500 1501 @override 1502 void didChangeDependencies() { 1503 super.didChangeDependencies(); 1504 localizations = MaterialLocalizations.of(context); 1505 _announceInitialTimeOnce(); 1506 _announceModeOnce(); 1507 } 1508 1509 _TimePickerMode _mode = _TimePickerMode.hour; 1510 _TimePickerMode _lastModeAnnounced; 1511 1512 TimeOfDay get selectedTime => _selectedTime; 1513 TimeOfDay _selectedTime; 1514 1515 Timer _vibrateTimer; 1516 MaterialLocalizations localizations; 1517 1518 void _vibrate() { 1519 switch (Theme.of(context).platform) { 1520 case TargetPlatform.android: 1521 case TargetPlatform.fuchsia: 1522 _vibrateTimer?.cancel(); 1523 _vibrateTimer = Timer(_kVibrateCommitDelay, () { 1524 HapticFeedback.vibrate(); 1525 _vibrateTimer = null; 1526 }); 1527 break; 1528 case TargetPlatform.iOS: 1529 break; 1530 } 1531 } 1532 1533 void _handleModeChanged(_TimePickerMode mode) { 1534 _vibrate(); 1535 setState(() { 1536 _mode = mode; 1537 _announceModeOnce(); 1538 }); 1539 } 1540 1541 void _announceModeOnce() { 1542 if (_lastModeAnnounced == _mode) { 1543 // Already announced it. 1544 return; 1545 } 1546 1547 switch (_mode) { 1548 case _TimePickerMode.hour: 1549 _announceToAccessibility(context, localizations.timePickerHourModeAnnouncement); 1550 break; 1551 case _TimePickerMode.minute: 1552 _announceToAccessibility(context, localizations.timePickerMinuteModeAnnouncement); 1553 break; 1554 } 1555 _lastModeAnnounced = _mode; 1556 } 1557 1558 bool _announcedInitialTime = false; 1559 1560 void _announceInitialTimeOnce() { 1561 if (_announcedInitialTime) 1562 return; 1563 1564 final MediaQueryData media = MediaQuery.of(context); 1565 final MaterialLocalizations localizations = MaterialLocalizations.of(context); 1566 _announceToAccessibility( 1567 context, 1568 localizations.formatTimeOfDay(widget.initialTime, alwaysUse24HourFormat: media.alwaysUse24HourFormat), 1569 ); 1570 _announcedInitialTime = true; 1571 } 1572 1573 void _handleTimeChanged(TimeOfDay value) { 1574 _vibrate(); 1575 setState(() { 1576 _selectedTime = value; 1577 }); 1578 } 1579 1580 void _handleHourSelected() { 1581 setState(() { 1582 _mode = _TimePickerMode.minute; 1583 }); 1584 } 1585 1586 void _handleCancel() { 1587 Navigator.pop(context); 1588 } 1589 1590 void _handleOk() { 1591 Navigator.pop(context, _selectedTime); 1592 } 1593 1594 @override 1595 Widget build(BuildContext context) { 1596 assert(debugCheckHasMediaQuery(context)); 1597 final MediaQueryData media = MediaQuery.of(context); 1598 final TimeOfDayFormat timeOfDayFormat = localizations.timeOfDayFormat(alwaysUse24HourFormat: media.alwaysUse24HourFormat); 1599 final bool use24HourDials = hourFormat(of: timeOfDayFormat) != HourFormat.h; 1600 final ThemeData theme = Theme.of(context); 1601 1602 final Widget picker = Padding( 1603 padding: const EdgeInsets.all(16.0), 1604 child: AspectRatio( 1605 aspectRatio: 1.0, 1606 child: _Dial( 1607 mode: _mode, 1608 use24HourDials: use24HourDials, 1609 selectedTime: _selectedTime, 1610 onChanged: _handleTimeChanged, 1611 onHourSelected: _handleHourSelected, 1612 ), 1613 ), 1614 ); 1615 1616 final Widget actions = ButtonTheme.bar( 1617 child: ButtonBar( 1618 children: <Widget>[ 1619 FlatButton( 1620 child: Text(localizations.cancelButtonLabel), 1621 onPressed: _handleCancel, 1622 ), 1623 FlatButton( 1624 child: Text(localizations.okButtonLabel), 1625 onPressed: _handleOk, 1626 ), 1627 ], 1628 ), 1629 ); 1630 1631 final Dialog dialog = Dialog( 1632 child: OrientationBuilder( 1633 builder: (BuildContext context, Orientation orientation) { 1634 final Widget header = _TimePickerHeader( 1635 selectedTime: _selectedTime, 1636 mode: _mode, 1637 orientation: orientation, 1638 onModeChanged: _handleModeChanged, 1639 onChanged: _handleTimeChanged, 1640 use24HourDials: use24HourDials, 1641 ); 1642 1643 final Widget pickerAndActions = Container( 1644 color: theme.dialogBackgroundColor, 1645 child: Column( 1646 mainAxisSize: MainAxisSize.min, 1647 children: <Widget>[ 1648 Expanded(child: picker), // picker grows and shrinks with the available space 1649 actions, 1650 ], 1651 ), 1652 ); 1653 1654 double timePickerHeightPortrait; 1655 double timePickerHeightLandscape; 1656 switch (theme.materialTapTargetSize) { 1657 case MaterialTapTargetSize.padded: 1658 timePickerHeightPortrait = _kTimePickerHeightPortrait; 1659 timePickerHeightLandscape = _kTimePickerHeightLandscape; 1660 break; 1661 case MaterialTapTargetSize.shrinkWrap: 1662 timePickerHeightPortrait = _kTimePickerHeightPortraitCollapsed; 1663 timePickerHeightLandscape = _kTimePickerHeightLandscapeCollapsed; 1664 break; 1665 } 1666 1667 assert(orientation != null); 1668 switch (orientation) { 1669 case Orientation.portrait: 1670 return SizedBox( 1671 width: _kTimePickerWidthPortrait, 1672 height: timePickerHeightPortrait, 1673 child: Column( 1674 mainAxisSize: MainAxisSize.min, 1675 crossAxisAlignment: CrossAxisAlignment.stretch, 1676 children: <Widget>[ 1677 header, 1678 Expanded( 1679 child: pickerAndActions, 1680 ), 1681 ], 1682 ), 1683 ); 1684 case Orientation.landscape: 1685 return SizedBox( 1686 width: _kTimePickerWidthLandscape, 1687 height: timePickerHeightLandscape, 1688 child: Row( 1689 mainAxisSize: MainAxisSize.min, 1690 crossAxisAlignment: CrossAxisAlignment.stretch, 1691 children: <Widget>[ 1692 header, 1693 Flexible( 1694 child: pickerAndActions, 1695 ), 1696 ], 1697 ), 1698 ); 1699 } 1700 return null; 1701 } 1702 ), 1703 ); 1704 1705 return Theme( 1706 data: theme.copyWith( 1707 dialogBackgroundColor: Colors.transparent, 1708 ), 1709 child: dialog, 1710 ); 1711 } 1712 1713 @override 1714 void dispose() { 1715 _vibrateTimer?.cancel(); 1716 _vibrateTimer = null; 1717 super.dispose(); 1718 } 1719} 1720 1721/// Shows a dialog containing a material design time picker. 1722/// 1723/// The returned Future resolves to the time selected by the user when the user 1724/// closes the dialog. If the user cancels the dialog, null is returned. 1725/// 1726/// {@tool sample} 1727/// Show a dialog with [initialTime] equal to the current time. 1728/// 1729/// ```dart 1730/// Future<TimeOfDay> selectedTime = showTimePicker( 1731/// initialTime: TimeOfDay.now(), 1732/// context: context, 1733/// ); 1734/// ``` 1735/// {@end-tool} 1736/// 1737/// The [context] argument is passed to [showDialog], the documentation for 1738/// which discusses how it is used. 1739/// 1740/// The [builder] parameter can be used to wrap the dialog widget 1741/// to add inherited widgets like [Localizations.override], 1742/// [Directionality], or [MediaQuery]. 1743/// 1744/// {@tool sample} 1745/// Show a dialog with the text direction overridden to be [TextDirection.rtl]. 1746/// 1747/// ```dart 1748/// Future<TimeOfDay> selectedTimeRTL = showTimePicker( 1749/// context: context, 1750/// initialTime: TimeOfDay.now(), 1751/// builder: (BuildContext context, Widget child) { 1752/// return Directionality( 1753/// textDirection: TextDirection.rtl, 1754/// child: child, 1755/// ); 1756/// }, 1757/// ); 1758/// ``` 1759/// {@end-tool} 1760/// 1761/// {@tool sample} 1762/// Show a dialog with time unconditionally displayed in 24 hour format. 1763/// 1764/// ```dart 1765/// Future<TimeOfDay> selectedTime24Hour = showTimePicker( 1766/// context: context, 1767/// initialTime: TimeOfDay(hour: 10, minute: 47), 1768/// builder: (BuildContext context, Widget child) { 1769/// return MediaQuery( 1770/// data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true), 1771/// child: child, 1772/// ); 1773/// }, 1774/// ); 1775/// ``` 1776/// {@end-tool} 1777/// 1778/// See also: 1779/// 1780/// * [showDatePicker], which shows a dialog that contains a material design 1781/// date picker. 1782Future<TimeOfDay> showTimePicker({ 1783 @required BuildContext context, 1784 @required TimeOfDay initialTime, 1785 TransitionBuilder builder, 1786}) async { 1787 assert(context != null); 1788 assert(initialTime != null); 1789 assert(debugCheckHasMaterialLocalizations(context)); 1790 1791 final Widget dialog = _TimePickerDialog(initialTime: initialTime); 1792 return await showDialog<TimeOfDay>( 1793 context: context, 1794 builder: (BuildContext context) { 1795 return builder == null ? dialog : builder(context, dialog); 1796 }, 1797 ); 1798} 1799 1800void _announceToAccessibility(BuildContext context, String message) { 1801 SemanticsService.announce(message, Directionality.of(context)); 1802} 1803