blob: 6e5042e6043661a69893d41928328a82e8004182 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math' as math;
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../app_bar.dart';
import '../back_button.dart';
import '../color_scheme.dart';
import '../debug.dart';
import '../dialog.dart';
import '../dialog_theme.dart';
import '../icon_button.dart';
import '../icons.dart';
import '../material_localizations.dart';
import '../scaffold.dart';
import '../text_button.dart';
import '../text_theme.dart';
import '../theme.dart';
import 'calendar_date_range_picker.dart';
import 'date_picker_common.dart';
import 'date_picker_header.dart';
import 'date_utils.dart' as utils;
import 'input_date_range_picker.dart';
const Size _inputPortraitDialogSize = Size(330.0, 270.0);
const Size _inputLandscapeDialogSize = Size(496, 164.0);
const Duration _dialogSizeAnimationDuration = Duration(milliseconds: 200);
const double _inputFormPortraitHeight = 98.0;
const double _inputFormLandscapeHeight = 108.0;
/// Shows a full screen modal dialog containing a Material Design date range
/// picker.
///
/// The returned [Future] resolves to the [DateTimeRange] selected by the user
/// when the user saves their selection. If the user cancels the dialog, null is
/// returned.
///
/// If [initialDateRange] is non-null, then it will be used as the initially
/// selected date range. If it is provided, [initialDateRange.start] must be
/// before or on [initialDateRange.end].
///
/// The [firstDate] is the earliest allowable date. The [lastDate] is the latest
/// allowable date. Both must be non-null.
///
/// If an initial date range is provided, [initialDateRange.start]
/// and [initialDateRange.end] must both fall between or on [firstDate] and
/// [lastDate]. For all of these [DateTime] values, only their dates are
/// considered. Their time fields are ignored.
///
/// The [currentDate] represents the current day (i.e. today). This
/// date will be highlighted in the day grid. If null, the date of
/// `DateTime.now()` will be used.
///
/// An optional [initialEntryMode] argument can be used to display the date
/// picker in the [DatePickerEntryMode.calendar] (a scrollable calendar month
/// grid) or [DatePickerEntryMode.input] (two text input fields) mode.
/// It defaults to [DatePickerEntryMode.calendar] and must be non-null.
///
/// The following optional string parameters allow you to override the default
/// text used for various parts of the dialog:
///
/// * [helpText], the label displayed at the top of the dialog.
/// * [cancelText], the label on the cancel button for the text input mode.
/// * [confirmText],the label on the ok button for the text input mode.
/// * [saveText], the label on the save button for the fullscreen calendar
/// mode.
/// * [errorFormatText], the message used when an input text isn't in a proper
/// date format.
/// * [errorInvalidText], the message used when an input text isn't a
/// selectable date.
/// * [errorInvalidRangeText], the message used when the date range is
/// invalid (e.g. start date is after end date).
/// * [fieldStartHintText], the text used to prompt the user when no text has
/// been entered in the start field.
/// * [fieldEndHintText], the text used to prompt the user when no text has
/// been entered in the end field.
/// * [fieldStartLabelText], the label for the start date text input field.
/// * [fieldEndLabelText], the label for the end date text input field.
///
/// An optional [locale] argument can be used to set the locale for the date
/// picker. It defaults to the ambient locale provided by [Localizations].
///
/// An optional [textDirection] argument can be used to set the text direction
/// ([TextDirection.ltr] or [TextDirection.rtl]) for the date picker. It
/// defaults to the ambient text direction provided by [Directionality]. If both
/// [locale] and [textDirection] are non-null, [textDirection] overrides the
/// direction chosen for the [locale].
///
/// The [context], [useRootNavigator] and [routeSettings] arguments are passed
/// to [showDialog], the documentation for which discusses how it is used.
/// [context] and [useRootNavigator] must be non-null.
///
/// The [builder] parameter can be used to wrap the dialog widget
/// to add inherited widgets like [Theme].
///
/// See also:
///
/// * [showDatePicker], which shows a material design date picker used to
/// select a single date.
/// * [DateTimeRange], which is used to describe a date range.
///
Future<DateTimeRange> showDateRangePicker({
required BuildContext context,
DateTimeRange? initialDateRange,
required DateTime firstDate,
required DateTime lastDate,
DateTime? currentDate,
DatePickerEntryMode initialEntryMode = DatePickerEntryMode.calendar,
String? helpText,
String? cancelText,
String? confirmText,
String? saveText,
String? errorFormatText,
String? errorInvalidText,
String? errorInvalidRangeText,
String? fieldStartHintText,
String? fieldEndHintText,
String? fieldStartLabelText,
String? fieldEndLabelText,
Locale? locale,
bool useRootNavigator = true,
RouteSettings? routeSettings,
TextDirection? textDirection,
TransitionBuilder? builder,
}) async {
assert(context != null);
assert(
initialDateRange == null || (initialDateRange.start != null && initialDateRange.end != null),
'initialDateRange must be null or have non-null start and end dates.'
);
assert(
initialDateRange == null || !initialDateRange.start.isAfter(initialDateRange.end),
'initialDateRange\'s start date must not be after it\'s end date.'
);
initialDateRange = initialDateRange == null ? null : utils.datesOnly(initialDateRange);
assert(firstDate != null);
firstDate = utils.dateOnly(firstDate);
assert(lastDate != null);
lastDate = utils.dateOnly(lastDate);
assert(
!lastDate.isBefore(firstDate),
'lastDate $lastDate must be on or after firstDate $firstDate.'
);
assert(
initialDateRange == null || !initialDateRange.start.isBefore(firstDate),
'initialDateRange\'s start date must be on or after firstDate $firstDate.'
);
assert(
initialDateRange == null || !initialDateRange.end.isBefore(firstDate),
'initialDateRange\'s end date must be on or after firstDate $firstDate.'
);
assert(
initialDateRange == null || !initialDateRange.start.isAfter(lastDate),
'initialDateRange\'s start date must be on or before lastDate $lastDate.'
);
assert(
initialDateRange == null || !initialDateRange.end.isAfter(lastDate),
'initialDateRange\'s end date must be on or before lastDate $lastDate.'
);
currentDate = utils.dateOnly(currentDate ?? DateTime.now());
assert(initialEntryMode != null);
assert(useRootNavigator != null);
assert(debugCheckHasMaterialLocalizations(context));
Widget dialog = _DateRangePickerDialog(
initialDateRange: initialDateRange,
firstDate: firstDate,
lastDate: lastDate,
currentDate: currentDate,
initialEntryMode: initialEntryMode,
helpText: helpText,
cancelText: cancelText,
confirmText: confirmText,
saveText: saveText,
errorFormatText: errorFormatText,
errorInvalidText: errorInvalidText,
errorInvalidRangeText: errorInvalidRangeText,
fieldStartHintText: fieldStartHintText,
fieldEndHintText: fieldEndHintText,
fieldStartLabelText: fieldStartLabelText,
fieldEndLabelText: fieldEndLabelText,
);
if (textDirection != null) {
dialog = Directionality(
textDirection: textDirection,
child: dialog,
);
}
if (locale != null) {
dialog = Localizations.override(
context: context,
locale: locale,
child: dialog,
);
}
return showDialog<DateTimeRange>(
context: context,
useRootNavigator: useRootNavigator,
routeSettings: routeSettings,
useSafeArea: false,
builder: (BuildContext context) {
return builder == null ? dialog : builder(context, dialog);
},
);
}
class _DateRangePickerDialog extends StatefulWidget {
const _DateRangePickerDialog({
Key? key,
this.initialDateRange,
required this.firstDate,
required this.lastDate,
this.currentDate,
this.initialEntryMode = DatePickerEntryMode.calendar,
this.helpText,
this.cancelText,
this.confirmText,
this.saveText,
this.errorInvalidRangeText,
this.errorFormatText,
this.errorInvalidText,
this.fieldStartHintText,
this.fieldEndHintText,
this.fieldStartLabelText,
this.fieldEndLabelText,
}) : super(key: key);
final DateTimeRange? initialDateRange;
final DateTime firstDate;
final DateTime lastDate;
final DateTime? currentDate;
final DatePickerEntryMode initialEntryMode;
final String? cancelText;
final String? confirmText;
final String? saveText;
final String? helpText;
final String? errorInvalidRangeText;
final String? errorFormatText;
final String? errorInvalidText;
final String? fieldStartHintText;
final String? fieldEndHintText;
final String? fieldStartLabelText;
final String? fieldEndLabelText;
@override
_DateRangePickerDialogState createState() => _DateRangePickerDialogState();
}
class _DateRangePickerDialogState extends State<_DateRangePickerDialog> {
late DatePickerEntryMode _entryMode;
DateTime? _selectedStart;
DateTime? _selectedEnd;
late bool _autoValidate;
final GlobalKey _calendarPickerKey = GlobalKey();
final GlobalKey<InputDateRangePickerState> _inputPickerKey = GlobalKey<InputDateRangePickerState>();
@override
void initState() {
super.initState();
_selectedStart = widget.initialDateRange?.start;
_selectedEnd = widget.initialDateRange?.end;
_entryMode = widget.initialEntryMode;
_autoValidate = false;
}
void _handleOk() {
if (_entryMode == DatePickerEntryMode.input) {
final InputDateRangePickerState picker = _inputPickerKey.currentState!;
if (!picker.validate()) {
setState(() {
_autoValidate = true;
});
return;
}
}
final DateTimeRange? selectedRange = _hasSelectedDateRange
? DateTimeRange(start: _selectedStart!, end: _selectedEnd!)
: null;
Navigator.pop(context, selectedRange);
}
void _handleCancel() {
Navigator.pop(context);
}
void _handleEntryModeToggle() {
setState(() {
switch (_entryMode) {
case DatePickerEntryMode.calendar:
_autoValidate = false;
_entryMode = DatePickerEntryMode.input;
break;
case DatePickerEntryMode.input:
// Validate the range dates
if (_selectedStart != null &&
(_selectedStart!.isBefore(widget.firstDate) || _selectedStart!.isAfter(widget.lastDate))) {
_selectedStart = null;
// With no valid start date, having an end date makes no sense for the UI.
_selectedEnd = null;
}
if (_selectedEnd != null &&
(_selectedEnd!.isBefore(widget.firstDate) || _selectedEnd!.isAfter(widget.lastDate))) {
_selectedEnd = null;
}
// If invalid range (start after end), then just use the start date
if (_selectedStart != null && _selectedEnd != null && _selectedStart!.isAfter(_selectedEnd!)) {
_selectedEnd = null;
}
_entryMode = DatePickerEntryMode.calendar;
break;
}
});
}
void _handleStartDateChanged(DateTime? date) {
setState(() => _selectedStart = date);
}
void _handleEndDateChanged(DateTime? date) {
setState(() => _selectedEnd = date);
}
bool get _hasSelectedDateRange => _selectedStart != null && _selectedEnd != null;
@override
Widget build(BuildContext context) {
final MediaQueryData mediaQuery = MediaQuery.of(context)!;
final Orientation orientation = mediaQuery.orientation;
final double textScaleFactor = math.min(mediaQuery.textScaleFactor, 1.3);
final MaterialLocalizations localizations = MaterialLocalizations.of(context)!;
final Widget contents;
final Size size;
ShapeBorder? shape;
final double elevation;
final EdgeInsets insetPadding;
switch (_entryMode) {
case DatePickerEntryMode.calendar:
contents = _CalendarRangePickerDialog(
key: _calendarPickerKey,
selectedStartDate: _selectedStart,
selectedEndDate: _selectedEnd,
firstDate: widget.firstDate,
lastDate: widget.lastDate,
currentDate: widget.currentDate,
onStartDateChanged: _handleStartDateChanged,
onEndDateChanged: _handleEndDateChanged,
onConfirm: _hasSelectedDateRange ? _handleOk : null,
onCancel: _handleCancel,
onToggleEntryMode: _handleEntryModeToggle,
confirmText: widget.saveText ?? localizations.saveButtonLabel,
helpText: widget.helpText ?? localizations.dateRangePickerHelpText,
);
size = mediaQuery.size;
insetPadding = const EdgeInsets.all(0.0);
shape = const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.zero)
);
elevation = 0;
break;
case DatePickerEntryMode.input:
contents = _InputDateRangePickerDialog(
selectedStartDate: _selectedStart,
selectedEndDate: _selectedEnd,
currentDate: widget.currentDate,
picker: Container(
padding: const EdgeInsets.symmetric(horizontal: 24),
height: orientation == Orientation.portrait
? _inputFormPortraitHeight
: _inputFormLandscapeHeight,
child: Column(
children: <Widget>[
const Spacer(),
InputDateRangePicker(
key: _inputPickerKey,
initialStartDate: _selectedStart,
initialEndDate: _selectedEnd,
firstDate: widget.firstDate,
lastDate: widget.lastDate,
onStartDateChanged: _handleStartDateChanged,
onEndDateChanged: _handleEndDateChanged,
autofocus: true,
autovalidate: _autoValidate,
helpText: widget.helpText,
errorInvalidRangeText: widget.errorInvalidRangeText,
errorFormatText: widget.errorFormatText,
errorInvalidText: widget.errorInvalidText,
fieldStartHintText: widget.fieldStartHintText,
fieldEndHintText: widget.fieldEndHintText,
fieldStartLabelText: widget.fieldStartLabelText,
fieldEndLabelText: widget.fieldEndLabelText,
),
const Spacer(),
],
),
),
onConfirm: _handleOk,
onCancel: _handleCancel,
onToggleEntryMode: _handleEntryModeToggle,
confirmText: widget.confirmText ?? localizations.okButtonLabel,
cancelText: widget.cancelText ?? localizations.cancelButtonLabel,
helpText: widget.helpText ?? localizations.dateRangePickerHelpText,
);
final DialogTheme dialogTheme = Theme.of(context)!.dialogTheme;
size = orientation == Orientation.portrait ? _inputPortraitDialogSize : _inputLandscapeDialogSize;
insetPadding = const EdgeInsets.symmetric(horizontal: 16.0, vertical: 24.0);
shape = dialogTheme.shape;
elevation = dialogTheme.elevation ?? 24;
break;
}
return Dialog(
child: AnimatedContainer(
width: size.width,
height: size.height,
duration: _dialogSizeAnimationDuration,
curve: Curves.easeIn,
child: MediaQuery(
data: MediaQuery.of(context)!.copyWith(
textScaleFactor: textScaleFactor,
),
child: Builder(builder: (BuildContext context) {
return contents;
}),
),
),
insetPadding: insetPadding,
shape: shape,
elevation: elevation,
clipBehavior: Clip.antiAlias,
);
}
}
class _CalendarRangePickerDialog extends StatelessWidget {
const _CalendarRangePickerDialog({
Key? key,
required this.selectedStartDate,
required this.selectedEndDate,
required this.firstDate,
required this.lastDate,
required this.currentDate,
required this.onStartDateChanged,
required this.onEndDateChanged,
required this.onConfirm,
required this.onCancel,
required this.onToggleEntryMode,
required this.confirmText,
required this.helpText,
}) : super(key: key);
final DateTime? selectedStartDate;
final DateTime? selectedEndDate;
final DateTime firstDate;
final DateTime lastDate;
final DateTime? currentDate;
final ValueChanged<DateTime> onStartDateChanged;
final ValueChanged<DateTime?> onEndDateChanged;
final VoidCallback? onConfirm;
final VoidCallback? onCancel;
final VoidCallback? onToggleEntryMode;
final String confirmText;
final String helpText;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context)!;
final ColorScheme colorScheme = theme.colorScheme;
final MaterialLocalizations localizations = MaterialLocalizations.of(context)!;
final Orientation orientation = MediaQuery.of(context)!.orientation;
final TextTheme textTheme = theme.textTheme;
final Color headerForeground = colorScheme.brightness == Brightness.light
? colorScheme.onPrimary
: colorScheme.onSurface;
final Color headerDisabledForeground = headerForeground.withOpacity(0.38);
final String startDateText = utils.formatRangeStartDate(localizations, selectedStartDate, selectedEndDate);
final String endDateText = utils.formatRangeEndDate(localizations, selectedStartDate, selectedEndDate, DateTime.now());
final TextStyle? headlineStyle = textTheme.headline5;
final TextStyle? startDateStyle = headlineStyle?.apply(
color: selectedStartDate != null ? headerForeground : headerDisabledForeground
);
final TextStyle? endDateStyle = headlineStyle?.apply(
color: selectedEndDate != null ? headerForeground : headerDisabledForeground
);
final TextStyle saveButtonStyle = textTheme.button!.apply(
color: onConfirm != null ? headerForeground : headerDisabledForeground
);
final IconButton entryModeIcon = IconButton(
padding: EdgeInsets.zero,
color: headerForeground,
icon: const Icon(Icons.edit),
tooltip: localizations.inputDateModeButtonLabel,
onPressed: onToggleEntryMode,
);
return SafeArea(
top: false,
left: false,
right: false,
child: Scaffold(
appBar: AppBar(
leading: CloseButton(
onPressed: onCancel,
),
actions: <Widget>[
if (orientation == Orientation.landscape) entryModeIcon,
TextButton(
onPressed: onConfirm,
child: Text(confirmText, style: saveButtonStyle),
),
const SizedBox(width: 8),
],
bottom: PreferredSize(
child: Row(children: <Widget>[
SizedBox(width: MediaQuery.of(context)!.size.width < 360 ? 42 : 72),
Expanded(
child: Semantics(
label: '$helpText $startDateText to $endDateText',
excludeSemantics: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
helpText,
style: textTheme.overline!.apply(
color: headerForeground,
),
),
const SizedBox(height: 8),
Row(
children: <Widget>[
Text(
startDateText,
style: startDateStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(' – ', style: startDateStyle,
),
Flexible(
child: Text(
endDateText,
style: endDateStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 16),
],
),
),
),
if (orientation == Orientation.portrait)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: entryModeIcon,
),
]),
preferredSize: const Size(double.infinity, 64),
),
),
body: CalendarDateRangePicker(
initialStartDate: selectedStartDate,
initialEndDate: selectedEndDate,
firstDate: firstDate,
lastDate: lastDate,
currentDate: currentDate,
onStartDateChanged: onStartDateChanged,
onEndDateChanged: onEndDateChanged,
),
),
);
}
}
class _InputDateRangePickerDialog extends StatelessWidget {
const _InputDateRangePickerDialog({
Key? key,
required this.selectedStartDate,
required this.selectedEndDate,
required this.currentDate,
required this.picker,
required this.onConfirm,
required this.onCancel,
required this.onToggleEntryMode,
required this.confirmText,
required this.cancelText,
required this.helpText,
}) : super(key: key);
final DateTime? selectedStartDate;
final DateTime? selectedEndDate;
final DateTime? currentDate;
final Widget picker;
final VoidCallback onConfirm;
final VoidCallback onCancel;
final VoidCallback onToggleEntryMode;
final String? confirmText;
final String? cancelText;
final String? helpText;
String _formatDateRange(BuildContext context, DateTime? start, DateTime? end, DateTime now) {
final MaterialLocalizations localizations = MaterialLocalizations.of(context)!;
final String startText = utils.formatRangeStartDate(localizations, start, end);
final String endText = utils.formatRangeEndDate(localizations, start, end, now);
if (start == null || end == null) {
return localizations.unspecifiedDateRange;
}
if (Directionality.of(context) == TextDirection.ltr) {
return '$startText – $endText';
} else {
return '$endText – $startText';
}
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context)!;
final ColorScheme colorScheme = theme.colorScheme;
final MaterialLocalizations localizations = MaterialLocalizations.of(context)!;
final Orientation orientation = MediaQuery.of(context)!.orientation;
final TextTheme textTheme = theme.textTheme;
final Color dateColor = colorScheme.brightness == Brightness.light
? colorScheme.onPrimary
: colorScheme.onSurface;
final TextStyle? dateStyle = orientation == Orientation.landscape
? textTheme.headline5?.apply(color: dateColor)
: textTheme.headline4?.apply(color: dateColor);
final String dateText = _formatDateRange(context, selectedStartDate, selectedEndDate, currentDate!);
final String semanticDateText = selectedStartDate != null && selectedEndDate != null
? '${localizations.formatMediumDate(selectedStartDate!)} – ${localizations.formatMediumDate(selectedEndDate!)}'
: '';
final Widget header = DatePickerHeader(
helpText: helpText ?? localizations.dateRangePickerHelpText,
titleText: dateText,
titleSemanticsLabel: semanticDateText,
titleStyle: dateStyle,
orientation: orientation,
isShort: orientation == Orientation.landscape,
icon: Icons.calendar_today,
iconTooltip: localizations.calendarModeButtonLabel,
onIconPressed: onToggleEntryMode,
);
final Widget actions = Container(
alignment: AlignmentDirectional.centerEnd,
constraints: const BoxConstraints(minHeight: 52.0),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: OverflowBar(
spacing: 8,
children: <Widget>[
TextButton(
child: Text(cancelText ?? localizations.cancelButtonLabel),
onPressed: onCancel,
),
TextButton(
child: Text(confirmText ?? localizations.okButtonLabel),
onPressed: onConfirm,
),
],
),
);
switch (orientation) {
case Orientation.portrait:
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
header,
Expanded(child: picker),
actions,
],
);
case Orientation.landscape:
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
header,
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Expanded(child: picker),
actions,
],
),
),
],
);
}
}
}