From 57982ef80d3409ba4a88c55438b328298033b304 Mon Sep 17 00:00:00 2001 From: Mairramer Date: Sat, 17 Jan 2026 11:15:11 -0300 Subject: [PATCH 1/2] Add customizable date input support to date picker and related tests --- .../lib/date_picker/show_date_picker.2.dart | 236 ++++++++++++++++++ .../date_picker/show_date_picker.2_test.dart | 53 ++++ packages/material_ui/lib/src/date.dart | 18 ++ packages/material_ui/lib/src/date_picker.dart | 25 ++ .../lib/src/input_date_picker_form_field.dart | 10 + .../material_ui/test/date_picker_test.dart | 127 ++++++++++ 6 files changed, 469 insertions(+) create mode 100644 packages/material_ui/example/lib/date_picker/show_date_picker.2.dart create mode 100644 packages/material_ui/example/test/date_picker/show_date_picker.2_test.dart diff --git a/packages/material_ui/example/lib/date_picker/show_date_picker.2.dart b/packages/material_ui/example/lib/date_picker/show_date_picker.2.dart new file mode 100644 index 000000000000..4ceea4402941 --- /dev/null +++ b/packages/material_ui/example/lib/date_picker/show_date_picker.2.dart @@ -0,0 +1,236 @@ +// 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 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample showing how to use [showDatePicker] with a custom +/// [DateInputCalendarDelegate] to support configurable text input formats. + +void main() => runApp(const DatePickerSampleApp()); + +class DatePickerSampleApp extends StatelessWidget { + const DatePickerSampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: DatePickerSample()); + } +} + +class DatePickerSample extends StatefulWidget { + const DatePickerSample({super.key}); + + @override + State createState() => _DatePickerSampleState(); +} + +class _DatePickerSampleState extends State { + DateTime? _selectedDate; + + DateInputFormat _formatType = DateInputFormat.dayMonthYear; + DateSeparator _separator = DateSeparator.dot; + + Future _showPicker() async { + final DateTime? result = await showDatePicker( + context: context, + initialDate: _selectedDate ?? DateTime(2021, 7, 25), + firstDate: DateTime(2021), + lastDate: DateTime(2999, 7, 25), + initialEntryMode: DatePickerEntryMode.input, + calendarDelegate: ConfigurableDateDelegate(formatType: _formatType, separator: _separator), + ); + + if (result != null) { + setState(() { + _selectedDate = result; + }); + } + } + + @override + Widget build(BuildContext context) { + final String label = _selectedDate == null + ? 'No date selected' + : DateInputFormatter(formatType: _formatType, separator: _separator.value).format(_selectedDate!); + + return Scaffold( + appBar: AppBar(title: const Text('showDatePicker with input delegate')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label), + const SizedBox(height: 24), + DropdownButtonFormField( + initialValue: _formatType, + decoration: const InputDecoration(labelText: 'Date format'), + items: DateInputFormat.values + .map((DateInputFormat format) => DropdownMenuItem(value: format, child: Text(format.patternLabel))) + .toList(), + onChanged: (DateInputFormat? value) { + if (value != null) { + setState(() => _formatType = value); + } + }, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _separator, + decoration: const InputDecoration(labelText: 'Separator'), + items: DateSeparator.values + .map((DateSeparator sep) => DropdownMenuItem(value: sep, child: Text(sep.value))) + .toList(), + onChanged: (DateSeparator? value) { + if (value != null) { + setState(() => _separator = value); + } + }, + ), + + const SizedBox(height: 24), + + OutlinedButton(onPressed: _showPicker, child: const Text('Select date')), + ], + ), + ), + ); + } +} + +enum DateInputFormat { + dayMonthYear(fieldLengths: [2, 2, 4], patternLabel: 'dd/mm/yyyy'), + monthDayYear(fieldLengths: [2, 2, 4], patternLabel: 'mm/dd/yyyy'), + yearMonthDay(fieldLengths: [4, 2, 2], patternLabel: 'yyyy/mm/dd'); + + const DateInputFormat({required this.fieldLengths, required this.patternLabel}); + + final List fieldLengths; + final String patternLabel; + + String pattern(String separator) => patternLabel.replaceAll('/', separator); +} + +enum DateSeparator { + slash('/'), + dash('-'), + dot('.'); + + const DateSeparator(this.value); + final String value; +} + +class DateInputFormatter extends TextInputFormatter { + const DateInputFormatter({required this.formatType, required this.separator}); + + final DateInputFormat formatType; + final String separator; + + String get pattern => formatType.pattern(separator); + + String format(DateTime date) { + final String day = date.day.toString().padLeft(2, '0'); + final String month = date.month.toString().padLeft(2, '0'); + final String year = date.year.toString().padLeft(4, '0'); + + return switch (formatType) { + DateInputFormat.dayMonthYear => '$day$separator$month$separator$year', + DateInputFormat.monthDayYear => '$month$separator$day$separator$year', + DateInputFormat.yearMonthDay => '$year$separator$month$separator$day', + }; + } + + DateTime? parse(String? input) { + if (input == null || input.isEmpty) { + return null; + } + + final List parts = input.split(separator); + if (parts.length != 3) { + return null; + } + + final (String d, String m, String y) = switch (formatType) { + DateInputFormat.dayMonthYear => (parts[0], parts[1], parts[2]), + DateInputFormat.monthDayYear => (parts[1], parts[0], parts[2]), + DateInputFormat.yearMonthDay => (parts[2], parts[1], parts[0]), + }; + + final int? day = int.tryParse(d); + final int? month = int.tryParse(m); + final int? year = int.tryParse(y); + + if (day == null || month == null || year == null) { + return null; + } + if (month < 1 || month > 12 || day < 1) { + return null; + } + + final int lastDayOfMonth = DateTime(year, month + 1, 0).day; + if (day > lastDayOfMonth) { + return null; + } + + return DateTime(year, month, day); + } + + @override + TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { + final String digits = newValue.text.replaceAll(RegExp(r'\D'), ''); + final int maxLength = formatType.fieldLengths.fold(0, (int a, int b) => a + b); + + if (digits.length > maxLength) { + return oldValue; + } + + final String formatted = _applyMask(digits); + return TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); + } + + String _applyMask(String digits) { + final StringBuffer buffer = StringBuffer(); + int offset = 0; + + for (int i = 0; i < formatType.fieldLengths.length && offset < digits.length; i++) { + final int len = formatType.fieldLengths[i]; + final int end = (offset + len).clamp(0, digits.length); + buffer.write(digits.substring(offset, end)); + offset = end; + + if (offset < digits.length && i < formatType.fieldLengths.length - 1) { + buffer.write(separator); + } + } + return buffer.toString(); + } +} + +class ConfigurableDateDelegate extends DateInputCalendarDelegate { + const ConfigurableDateDelegate({required this.formatType, this.separator = DateSeparator.slash}); + + final DateInputFormat formatType; + final DateSeparator separator; + + DateInputFormatter get _formatter => DateInputFormatter(formatType: formatType, separator: separator.value); + + @override + List get inputFormatters => [ + FilteringTextInputFormatter.digitsOnly, + _formatter, + ]; + + @override + String dateHelpText(MaterialLocalizations localizations) => _formatter.pattern; + + @override + String formatCompactDate(DateTime date, MaterialLocalizations localizations) => _formatter.format(date); + + @override + DateTime? parseCompactDate(String? inputString, MaterialLocalizations localizations) => _formatter.parse(inputString); +} diff --git a/packages/material_ui/example/test/date_picker/show_date_picker.2_test.dart b/packages/material_ui/example/test/date_picker/show_date_picker.2_test.dart new file mode 100644 index 000000000000..258f4fd8c2e2 --- /dev/null +++ b/packages/material_ui/example/test/date_picker/show_date_picker.2_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui_examples/date_picker/show_date_picker.2.dart' as example; + +void main() { + testWidgets('renders selected date text according to formatType and separator (input mode)', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: example.DatePickerSample())); + + expect(find.text('No date selected'), findsOneWidget); + + // Open date picker. + await tester.tap(find.byType(OutlinedButton)); + await tester.pumpAndSettle(); + + final Finder textField = find.byType(TextField); + expect(textField, findsOneWidget); + + // Enter date: 30/07/2021. + await tester.enterText(textField, '30072021'); + await tester.pump(); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + expect(find.text('30.07.2021'), findsOneWidget); + + // Change format to monthDayYear. + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('mm/dd/yyyy').last); + await tester.pumpAndSettle(); + + expect(find.text('07.30.2021'), findsOneWidget); + + // Change separator to dash. + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('-').last); + await tester.pumpAndSettle(); + + expect(find.text('07-30-2021'), findsOneWidget); + + // Change format to yearMonthDay. + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('yyyy/mm/dd').last); + await tester.pumpAndSettle(); + + expect(find.text('2021-07-30'), findsOneWidget); + }); +} diff --git a/packages/material_ui/lib/src/date.dart b/packages/material_ui/lib/src/date.dart index b71d30f012e0..6631deb8941e 100644 --- a/packages/material_ui/lib/src/date.dart +++ b/packages/material_ui/lib/src/date.dart @@ -8,8 +8,11 @@ library; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; +import 'date_picker.dart'; +import 'input_date_picker_form_field.dart'; import 'material_localizations.dart'; /// Controls the calendar system used in the date picker. @@ -34,6 +37,8 @@ import 'material_localizations.dart'; /// See also: /// /// * [GregorianCalendarDelegate], the default implementation for the Gregorian calendar. +/// * [DateInputCalendarDelegate], an interface for delegates that support +/// customizing date text input and formatting. /// * [CalendarDatePicker], which uses this delegate to manage calendar-specific behavior. abstract class CalendarDelegate { /// Creates a calendar delegate. @@ -249,6 +254,19 @@ class GregorianCalendarDelegate extends CalendarDelegate { } } +/// A [GregorianCalendarDelegate] that synchronizes calendar operations with +/// text input requirements, such as [inputFormatters]. +/// +/// Subclasses must provide the appropriate [inputFormatters] to ensure +/// that user entry matches the parsing logic in [parseCompactDate]. +abstract class DateInputCalendarDelegate extends GregorianCalendarDelegate { + /// Creates a calendar delegate. + const DateInputCalendarDelegate(); + + /// The formatters applied to the text field to guide and validate user input. + List get inputFormatters; +} + /// Utility functions for working with dates. abstract final class DateUtils { /// {@template material_ui.date.dateOnly} diff --git a/packages/material_ui/lib/src/date_picker.dart b/packages/material_ui/lib/src/date_picker.dart index c4b3354b26d4..a1c3f7373082 100644 --- a/packages/material_ui/lib/src/date_picker.dart +++ b/packages/material_ui/lib/src/date_picker.dart @@ -124,6 +124,21 @@ const double _fontSizeToScale = 14.0; /// /// {@macro material_ui.calendar_date_picker.calendarDelegate} /// +/// Use [DateInputCalendarDelegate] to customize how dates are entered and +/// formatted in [DatePickerEntryMode.input]. +/// +/// A custom delegate can define specific date input conventions, such as +/// ordering, separators, or formatting rules (for example, `dd.MM.yyyy`), and +/// is responsible for keeping text input parsing and calendar selection +/// synchronized. +/// +/// {@tool dartpad} +/// This sample shows how to customize the text input behavior of +/// [showDatePicker] using a [DateInputCalendarDelegate]. +/// +/// ** See code in examples/api/lib/material/date_picker/show_date_picker.2.dart ** +/// {@end-tool} +/// /// The following optional string parameters allow you to override the default /// text used for various parts of the dialog: /// @@ -3485,6 +3500,7 @@ class _InputDateRangePickerState extends State<_InputDateRangePicker> { keyboardType: widget.keyboardType, onChanged: _handleStartChanged, autofocus: widget.autofocus, + inputFormatters: [...?_textInputFormatter], ), ), const SizedBox(width: 8), @@ -3501,9 +3517,18 @@ class _InputDateRangePickerState extends State<_InputDateRangePicker> { ), keyboardType: widget.keyboardType, onChanged: _handleEndChanged, + inputFormatters: [...?_textInputFormatter], ), ), ], ); } + + List? get _textInputFormatter { + return switch (widget.calendarDelegate) { + DateInputCalendarDelegate(:final List? inputFormatters) => + inputFormatters, + _ => null, + }; + } } diff --git a/packages/material_ui/lib/src/input_date_picker_form_field.dart b/packages/material_ui/lib/src/input_date_picker_form_field.dart index 18c6f883e311..7348f161a274 100644 --- a/packages/material_ui/lib/src/input_date_picker_form_field.dart +++ b/packages/material_ui/lib/src/input_date_picker_form_field.dart @@ -6,6 +6,7 @@ /// @docImport 'text_field.dart'; library; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'date.dart'; @@ -284,7 +285,16 @@ class _InputDatePickerFormFieldState extends State { autofocus: widget.autofocus, controller: _controller, focusNode: widget.focusNode, + inputFormatters: [...?_textInputFormatter], ), ); } + + List? get _textInputFormatter { + return switch (widget.calendarDelegate) { + DateInputCalendarDelegate(:final List? inputFormatters) => + inputFormatters, + _ => null, + }; + } } diff --git a/packages/material_ui/test/date_picker_test.dart b/packages/material_ui/test/date_picker_test.dart index 57d0e0c66abd..95b62df0229c 100644 --- a/packages/material_ui/test/date_picker_test.dart +++ b/packages/material_ui/test/date_picker_test.dart @@ -13,6 +13,7 @@ import 'dart:ui'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; + import 'package:material_ui/material_ui.dart'; import 'clipboard_utils.dart'; @@ -2773,6 +2774,111 @@ void main() { ); expect(tester.getSize(find.byType(DatePickerDialog)).isEmpty, isTrue); }); + + group('DateInputCalendarDelegate', () { + Widget buildApp() { + return MaterialApp( + home: Material( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('Open'), + onPressed: () { + showDatePicker( + context: context, + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + initialEntryMode: DatePickerEntryMode.input, + calendarDelegate: const TestDateInputCalendarDelegate(), + ); + }, + ); + }, + ), + ), + ); + } + + testWidgets('applies inputFormatters and allows text entry parsing', ( + WidgetTester tester, + ) async { + late DateTime? result; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Builder( + builder: (BuildContext context) { + return ElevatedButton( + child: const Text('Open'), + onPressed: () async { + result = await showDatePicker( + context: context, + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + initialEntryMode: DatePickerEntryMode.input, + calendarDelegate: const TestDateInputCalendarDelegate(), + ); + }, + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '20240620'); + await tester.pump(); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + expect(result, DateTime(2024, 6, 20)); + }); + + testWidgets('filters non-digit input via inputFormatters', (WidgetTester tester) async { + await tester.pumpWidget(buildApp()); + + await tester.tap(find.text('Open')); + await tester.pump(); + + final Finder textField = find.byType(TextField); + expect(textField, findsOneWidget); + + await tester.enterText(textField, '2024-06-20'); + await tester.pump(); + + final TextField field = tester.widget(textField); + final TextEditingController controller = field.controller!; + // Only digits should remain. + expect(controller.text, '20240620'); + }); + + testWidgets('rejects alphabetic characters via inputFormatters', (WidgetTester tester) async { + await tester.pumpWidget(buildApp()); + + await tester.tap(find.text('Open')); + await tester.pump(); + + final Finder textField = find.byType(TextField); + expect(textField, findsOneWidget); + + // Try to enter letters mixed with digits. + await tester.enterText(textField, '20ab24cd0620'); + await tester.pump(); + + final TextField field = tester.widget(textField); + final TextEditingController controller = field.controller!; + + // Alphabetic characters should be filtered out. + expect(controller.text, '20240620'); + }); + }); } class _RestorableDatePickerDialogTestWidget extends StatefulWidget { @@ -2904,3 +3010,24 @@ class TestCalendarDelegate extends GregorianCalendarDelegate { return 1; } } + +class TestDateInputCalendarDelegate extends DateInputCalendarDelegate { + const TestDateInputCalendarDelegate(); + + @override + List get inputFormatters => [ + FilteringTextInputFormatter.digitsOnly, + ]; + + @override + DateTime? parseCompactDate(String? inputString, MaterialLocalizations localizations) { + // yyyyMMdd + if (inputString == null || inputString.length != 8) { + return null; + } + final int year = int.parse(inputString.substring(0, 4)); + final int month = int.parse(inputString.substring(4, 6)); + final int day = int.parse(inputString.substring(6, 8)); + return DateTime(year, month, day); + } +} From 519235d7695f8156bdbe6b38833ce3e19f70ad1c Mon Sep 17 00:00:00 2001 From: Mairramer Date: Wed, 19 Aug 2026 09:09:07 -0300 Subject: [PATCH 2/2] Add changelog entry for customizable date input formatters in Material date picker --- .../change_2026_08_19_date_input_formatters.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/material_ui/pending_changelogs/change_2026_08_19_date_input_formatters.yaml diff --git a/packages/material_ui/pending_changelogs/change_2026_08_19_date_input_formatters.yaml b/packages/material_ui/pending_changelogs/change_2026_08_19_date_input_formatters.yaml new file mode 100644 index 000000000000..32e38435f43c --- /dev/null +++ b/packages/material_ui/pending_changelogs/change_2026_08_19_date_input_formatters.yaml @@ -0,0 +1,5 @@ +changelog: | + - Adds support for customizing date input formatters via + `CalendarDelegate.keyboardInputFormatters`, allowing custom date input + behavior in the Material date picker. +version: minor