Fliin
On this page

Components / forms

Date field

Types a known date on the keyboard, day, month and year in the order of the language, each segment read out by name and value and adjustable by swipe.

16 of 16 variants pass
Date field, example state
Platform
Brightness
Text scale
Direction

Import

In a Flutter app where fliin init has run:

fliin add date-fieldAlso installs: core, icons, field, text-field

Examples

Example

A date typed segment by segment, in the order of the user's locale; no calendar to open.

Date field, example state
import 'package:your_app/ui/fliin/components/date_field/fl_date_field.dart';
import 'package:flutter/widgets.dart';

class DateFieldExample extends StatefulWidget {
  const DateFieldExample({super.key});

  @override
  State<DateFieldExample> createState() => _DateFieldExampleState();
}

class _DateFieldExampleState extends State<DateFieldExample> {
  DateTime? _value;

  @override
  Widget build(BuildContext context) {
    return FlDateField(
      label: 'Birth date',
      value: _value,
      onChanged: (date) => setState(() => _value = date),
    );
  }
}

Focused

Focused, its first segment ready for digits.

Date field, focused state
import 'package:your_app/ui/fliin/components/date_field/fl_date_field.dart';
import 'package:flutter/widgets.dart';

class DateFieldFocusedExample extends StatefulWidget {
  const DateFieldFocusedExample({super.key});

  @override
  State<DateFieldFocusedExample> createState() => _DateFieldFocusedExampleState();
}

class _DateFieldFocusedExampleState extends State<DateFieldFocusedExample> {
  DateTime? _value;

  @override
  Widget build(BuildContext context) {
    return FlDateField(
      label: 'Birth date',
      value: _value,
      onChanged: (date) => setState(() => _value = date),
      autofocus: true,
    );
  }
}

Filled

A complete date, one segment per part.

Date field, filled state
import 'package:your_app/ui/fliin/components/date_field/fl_date_field.dart';
import 'package:flutter/widgets.dart';

class DateFieldFilledExample extends StatefulWidget {
  const DateFieldFilledExample({super.key});

  @override
  State<DateFieldFilledExample> createState() => _DateFieldFilledExampleState();
}

class _DateFieldFilledExampleState extends State<DateFieldFilledExample> {
  DateTime? _value = DateTime(1987, 10, 14);

  @override
  Widget build(BuildContext context) {
    return FlDateField(
      label: 'Birth date',
      value: _value,
      onChanged: (date) => setState(() => _value = date),
    );
  }
}

Placeholder

Empty: each segment shows what it expects.

Date field, placeholder state
import 'package:your_app/ui/fliin/components/date_field/fl_date_field.dart';
import 'package:flutter/widgets.dart';

class DateFieldPlaceholderExample extends StatefulWidget {
  const DateFieldPlaceholderExample({super.key});

  @override
  State<DateFieldPlaceholderExample> createState() => _DateFieldPlaceholderExampleState();
}

class _DateFieldPlaceholderExampleState extends State<DateFieldPlaceholderExample> {
  DateTime? _value;

  @override
  Widget build(BuildContext context) {
    return FlDateField(
      label: 'Birth date',
      value: _value,
      onChanged: (date) => setState(() => _value = date),
    );
  }
}

Invalid

error turns the field red and says why, below it.

Date field, invalid state
import 'package:your_app/ui/fliin/components/date_field/fl_date_field.dart';
import 'package:flutter/widgets.dart';

class DateFieldInvalidExample extends StatefulWidget {
  const DateFieldInvalidExample({super.key});

  @override
  State<DateFieldInvalidExample> createState() => _DateFieldInvalidExampleState();
}

class _DateFieldInvalidExampleState extends State<DateFieldInvalidExample> {
  DateTime? _value = DateTime(2031, 3, 9);

  @override
  Widget build(BuildContext context) {
    return FlDateField(
      label: 'Birth date',
      value: _value,
      onChanged: (date) => setState(() => _value = date),
      error: 'Must be in the past',
    );
  }
}

Disabled

onChanged: null disables the field: the date stays readable.

Date field, disabled state
import 'package:your_app/ui/fliin/components/date_field/fl_date_field.dart';
import 'package:flutter/widgets.dart';

class DateFieldDisabledExample extends StatelessWidget {
  const DateFieldDisabledExample({super.key});

  @override
  Widget build(BuildContext context) {
    return FlDateField(
      label: 'Birth date',
      value: DateTime(1987, 10, 14),
      onChanged: null,
    );
  }
}

On surface

On a card, FlInputVariant.secondary fills the field instead of shadowing it, as every input on a surface.

Date field, on-surface state
import 'package:your_app/ui/fliin/components/date_field/fl_date_field.dart';
import 'package:your_app/ui/fliin/components/surface/fl_surface.dart';
import 'package:flutter/widgets.dart';

class DateFieldOnSurfaceExample extends StatefulWidget {
  const DateFieldOnSurfaceExample({super.key});

  @override
  State<DateFieldOnSurfaceExample> createState() => _DateFieldOnSurfaceExampleState();
}

class _DateFieldOnSurfaceExampleState extends State<DateFieldOnSurfaceExample> {
  DateTime? _value;

  @override
  Widget build(BuildContext context) {
    return FlSurface(
      child: FlDateField(
        label: 'Start date',
        description: 'The first day of the contract.',
        variant: FlInputVariant.secondary,
        value: _value,
        onChanged: (date) => setState(() => _value = date),
      ),
    );
  }
}

Prefix

leading puts an icon or a word before the segments; showIcon: false drops the calendar at the end, so that the field carries only one.

Date field, prefix state
import 'package:your_app/ui/fliin/components/date_field/fl_date_field.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:your_app/ui/fliin/icons/fl_icons.dart';
import 'package:flutter/widgets.dart';

class DateFieldPrefixExample extends StatefulWidget {
  const DateFieldPrefixExample({super.key});

  @override
  State<DateFieldPrefixExample> createState() => _DateFieldPrefixExampleState();
}

class _DateFieldPrefixExampleState extends State<DateFieldPrefixExample> {
  DateTime? _value;

  @override
  Widget build(BuildContext context) {
    return FlDateField(
      label: 'Due date',
      leading: FlIcon(FlIcons.calendar),
      showIcon: false,
      required: true,
      value: _value,
      onChanged: (date) => setState(() => _value = date),
    );
  }
}

Usage

The code the reference images render:

ColoredBox(color: FlScope.of(context).colors.surface, child: SingleChildScrollView(padding: EdgeInsetsDirectional.all(FlScope.of(context).space.lg), child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, spacing: FlScope.of(context).space.sm, children: [FlDateField(label: 'Date of birth', value: null, onChanged: (_) {}, autofocus: true, strings: FlDateFieldStrings.language'en'), FlDateField(label: 'Expiry date', value: DateTime(2031, 3, 9), onChanged: (_) {}, strings: FlDateFieldStrings.language'en'), FlDateField(label: 'Arrival date', description: 'As shown on the ticket', required: true, value: null, onChanged: (_) {}, strings: FlDateFieldStrings.language'en'), FlDateField(label: 'Delivery date', error: 'This date has passed', value: DateTime(2026, 10, 2), onChanged: (_) {}, strings: FlDateFieldStrings.language'en'), FlDateField(label: 'Send date', value: DateTime(2026, 10, 14), onChanged: null, strings: FlDateFieldStrings.language'en')])))

API

FlDateField: class extends StatelessWidget
FlDateField.new: const new({super.key, required String label, required DateTime? value, required ValueChanged<DateTime?>? onChanged, String? description, String? error, bool required, bool autofocus, FlDateFieldStrings? strings, Widget? leading, bool showIcon, FlInputVariant variant})
FlDateField.autofocus: get bool
FlDateField.label: get String
FlDateField.value: get DateTime?
FlDateField.onChanged: get ValueChanged<DateTime?>?
FlDateField.description: get String?
FlDateField.error: get String?
FlDateField.required: get bool
FlDateField.strings: get FlDateFieldStrings?
FlDateField.leading: get Widget?
FlDateField.showIcon: get bool
FlDateField.variant: get FlInputVariant
FlDateField.build: Widget(BuildContext context)
FlSegmentedField: class extends StatefulWidget
FlSegmentedField.new: const new({super.key, required String label, required List<FlDateSegment> segments, required Map<FlDateSegment, int> values, required ValueChanged<Map<FlDateSegment, int>?>? onChanged, required FlDateFieldStrings strings, Map<FlDateSegment, int> Function(Map<FlDateSegment, int> values)? complete, String? description, String? error, FlIconData? icon, bool required, bool autofocus, Widget? leading, FlInputVariant variant})
FlSegmentedField.autofocus: get bool
FlSegmentedField.label: get String
FlSegmentedField.segments: get List<FlDateSegment>
FlSegmentedField.values: get Map<FlDateSegment, int>
FlSegmentedField.onChanged: get ValueChanged<Map<FlDateSegment, int>?>?
FlSegmentedField.strings: get FlDateFieldStrings
FlSegmentedField.complete: get Map<FlDateSegment, int> Function(Map<FlDateSegment, int> values)?
FlSegmentedField.icon: get FlIconData?
FlSegmentedField.description: get String?
FlSegmentedField.error: get String?
FlSegmentedField.required: get bool
FlSegmentedField.leading: get Widget?
FlSegmentedField.variant: get FlInputVariant
FlSegmentedField.createState: State<FlSegmentedField>()
FlDateSegment: enum {day, month, year, hour, minute, dayPeriod}
FlDateSegment.day: static final FlDateSegment
FlDateSegment.month: static final FlDateSegment
FlDateSegment.year: static final FlDateSegment
FlDateSegment.hour: static final FlDateSegment
FlDateSegment.minute: static final FlDateSegment
FlDateSegment.dayPeriod: static final FlDateSegment
flDateFieldL10n: const Map<String, Map<String, String>>
FlDateFieldStrings: final class
FlDateFieldStrings.new: new({required List<FlDateSegment> order, required String separator, required Map<FlDateSegment, String> placeholders, required Map<FlDateSegment, String> names, required String empty, required List<String> months, required String digits, required bool hour12, required List<String> dayPeriods, required String timeSeparator})
FlDateFieldStrings.fromTable: factory new(Map<String, String> table)
FlDateFieldStrings.language: factory new(String language)
FlDateFieldStrings.of: factory new(BuildContext context)
FlDateFieldStrings.languages: static get Set<String>
FlDateFieldStrings.order: get List<FlDateSegment>
FlDateFieldStrings.separator: get String
FlDateFieldStrings.placeholders: get Map<FlDateSegment, String>
FlDateFieldStrings.names: get Map<FlDateSegment, String>
FlDateFieldStrings.empty: get String
FlDateFieldStrings.months: get List<String>
FlDateFieldStrings.digits: get String
FlDateFieldStrings.hour12: get bool
FlDateFieldStrings.dayPeriods: get List<String>
FlDateFieldStrings.timeSeparator: get String
FlDateFieldStrings.number: String(int value, {int width})
FlDateFieldStrings.digitOf: int?(String character)

Reference renders

The 16 images published with version 0.2.4, rendered by the shared harness on macOS with Flutter 3.47.7. fliin verify date-field compares your copy to them.

iOS · Light
Date field, iOS, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Date field, iOS, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Date field, iOS, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Date field, iOS, Light, text 2.0×, Right to left
Text 2.0× · Right to left
iOS · Dark
Date field, iOS, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Date field, iOS, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Date field, iOS, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Date field, iOS, Dark, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Light
Date field, Android, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Date field, Android, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Date field, Android, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Date field, Android, Light, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Dark
Date field, Android, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Date field, Android, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Date field, Android, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Date field, Android, Dark, text 2.0×, Right to left
Text 2.0× · Right to left

Use it to

  • ask for a birth date with the keyboard
  • make a date input split into segments
  • ask for the expiry date of a card
  • collect a known date without a calendar
  • adjust each part of a date with arrow keys
Not for
  • a date picked by browsing months: use a date picker (date-picker)
  • a time of day: use a time field (time-field)
States
  • Focused
  • Filled
  • Placeholder
  • Invalid
  • Disabled

Source

The files fliin add date-field copies into your app, before your prefix and imports are applied.

date_field/fl_date_field.dart

// Modified from the DateField of HeroUI React v3, Copyright 2020 Next UI Inc.,
// licensed under the MIT license below and under Apache 2.0:
// https://www.apache.org/licenses/LICENSE-2.0
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.
import 'package:flutter/widgets.dart';
import 'package:fliin_ui/components/date_field/fl_segmented_field.dart';
import 'package:fliin_ui/icons/fl_icons.dart';

export 'package:fliin_ui/components/date_field/fl_segmented_field.dart';

/// Keyboard entry of a date, by segments: the
/// day, the month and the year in the language's order ("jj/mm/aaaa" in
/// French, "mm/dd/yyyy" in English), each read by the screen reader under
/// its name and value, and adjustable through the increase and decrease
/// gestures.
///
/// For a date the user knows, which they type faster than they would look
/// it up (birth, document expiry). For a date chosen within a month,
/// `date-picker`.
///
/// The input and the keyboard are those of [FlSegmentedField]. The date is
/// sent to [onChanged] as soon as the three segments are filled, the day
/// clamped to the last of the month (February 31: 28 or 29); a segment
/// emptied afterwards returns `null`. The field shows [value]: a date the
/// app does not pass back reverts to the app's.
class FlDateField extends StatelessWidget {
  /// A field whose date is [value].
  const FlDateField({
    super.key,
    required this.label,
    required this.value,
    required this.onChanged,
    this.description,
    this.error,
    this.required = false,
    this.autofocus = false,
    this.strings,
    this.leading,
    this.showIcon = true,
    this.variant = FlInputVariant.primary,
  });

  /// True: the first empty segment takes the focus on first render.
  final bool autofocus;

  /// The field's name, displayed above and read by the screen reader.
  final String label;

  /// The date, without time; null with no value.
  final DateTime? value;

  /// Receives `DateTime(year, month, day)` when the date is complete, or
  /// `null` when it stops being so; null disables the field.
  final ValueChanged<DateTime?>? onChanged;

  /// The help under the field, hidden by [error].
  final String? description;

  /// When non-null: the field is invalid (a date outside the app's bounds),
  /// and this text replaces [description].
  final String? error;

  /// True: an asterisk follows the label, and the field announces itself required.
  final bool required;

  /// The order, the texts and the digits; those of the app's language by
  /// default.
  final FlDateFieldStrings? strings;

  /// See [FlSegmentedField.leading]: an icon or a word before the segments.
  final Widget? leading;

  /// True: the `calendar` icon at the end of the field; false,
  /// none, for a field that carries its own [leading].
  final bool showIcon;

  /// See [FlSegmentedField.variant].
  final FlInputVariant variant;

  @override
  Widget build(BuildContext context) {
    final strings = this.strings ?? FlDateFieldStrings.of(context);
    final value = this.value;
    final onChanged = this.onChanged;
    return FlSegmentedField(
      label: label,
      segments: strings.order,
      values: value == null
          ? const {}
          : {FlDateSegment.day: value.day, FlDateSegment.month: value.month, FlDateSegment.year: value.year},
      strings: strings,
      description: description,
      error: error,
      icon: showIcon ? FlIcons.calendar : null,
      leading: leading,
      variant: variant,
      required: required,
      autofocus: autofocus,
      complete: (values) {
        final year = values[FlDateSegment.year]!;
        final month = values[FlDateSegment.month]!;
        final last = DateTime(year, month + 1, 0).day;
        final day = values[FlDateSegment.day]!;
        return {...values, FlDateSegment.day: day > last ? last : day};
      },
      onChanged: onChanged == null
          ? null
          : (values) => onChanged(
              values == null
                  ? null
                  : DateTime(values[FlDateSegment.year]!, values[FlDateSegment.month]!, values[FlDateSegment.day]!),
            ),
    );
  }
}

date_field/fl_segmented_field.dart

// Modified from the DateField and TimeField of HeroUI React v3, Copyright 2020
// Next UI Inc., licensed under the MIT license below and under Apache 2.0:
// https://www.apache.org/licenses/LICENSE-2.0
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.
//
// Also modified from the Input frame of HeroUI Native 1.0.10, Copyright 2025
// NextUI Inc., licensed under Apache 2.0.
import 'package:flutter/foundation.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:fliin_ui/components/date_field/fl_date_field_strings.dart';
import 'package:fliin_ui/components/field/fl_field.dart';
import 'package:fliin_ui/components/text_field/fl_input.dart';
import 'package:fliin_ui/core/fliin.dart';

export 'package:fliin_ui/components/date_field/fl_date_field_strings.dart';
export 'package:fliin_ui/components/text_field/fl_input.dart' show FlInputVariant;

/// The end icon, Izydesk's `.input .i`.
const double _iconSize = 16;

/// What the virtual keyboard keeps between two inputs: two zero-width
/// spaces, so that a deletion on an empty field still arrives (iOS sends
/// nothing when the text is empty).
const String _keep = '\u200b\u200b';
const TextEditingValue _baseline = TextEditingValue(text: _keep, selection: TextSelection.collapsed(offset: 2));

/// The padding of a segment (`px-0.5`).
const double _segmentPadding = 2;

/// The bounds and the width of a segment.
typedef _Range = ({int min, int max, int width});

/// A segmented input, the group of a date or time field: the `FlLabel`
/// label, an input frame
/// (`FlInputFrame`) that carries one segment per component, separated by
/// their signs, then the description or the error. `FlDateField` and
/// `FlTimeField` set it up; an app goes through them.
///
/// Typed digits fill the current segment and move to the next when it is
/// full, or when one more digit would exceed its maximum ("4" for a day).
/// The up and down arrows change the segment, which wraps around; left and
/// right move from one segment to another in reading order; Backspace
/// deletes a digit, then goes back to the previous segment. `a` and `p`
/// select the morning or the afternoon.
///
/// On a phone, tapping the frame opens the number pad on the first empty
/// segment: input advances by itself, without aiming at a segment with the
/// finger. The value is sent as soon as all segments are filled, without
/// waiting for the Return key, which the iOS pad lacks; tapping outside the
/// field closes the keyboard.
class FlSegmentedField extends StatefulWidget {
  /// A field of [segments], in display order.
  const FlSegmentedField({
    super.key,
    required this.label,
    required this.segments,
    required this.values,
    required this.onChanged,
    required this.strings,
    this.complete,
    this.description,
    this.error,
    this.icon,
    this.required = false,
    this.autofocus = false,
    this.leading,
    this.variant = FlInputVariant.primary,
  });

  /// True: the first empty segment takes the focus on first render, if
  /// nothing else in its scope has it.
  final bool autofocus;

  /// The field's name, displayed above and read by the screen reader.
  final String label;

  /// The segments, in display order.
  final List<FlDateSegment> segments;

  /// The app's value, segment by segment; empty with no value.
  final Map<FlDateSegment, int> values;

  /// Receives all segments when they are filled, or `null` when a segment
  /// is emptied afterwards; null disables the field.
  final ValueChanged<Map<FlDateSegment, int>?>? onChanged;

  /// The field's texts.
  final FlDateFieldStrings strings;

  /// Brings filled segments back to a valid value (the 31st of a 30-day
  /// month becomes the 30th); the field shows what it returns.
  final Map<FlDateSegment, int> Function(Map<FlDateSegment, int> values)? complete;

  /// A decorative icon at the end of the field (`calendar`, `clock`), in
  /// `onSurfaceMuted`; none when null.
  final FlIconData? icon;

  /// The help under the field, hidden by [error].
  final String? description;

  /// When non-null: the field is invalid, and this text replaces [description].
  final String? error;

  /// True: an asterisk follows the label, and the field announces itself required.
  final bool required;

  /// Content at the start of the frame, before the segments: an icon, a short word. Decorative: the field is named by
  /// [label]. Icons take `onSurfaceMuted` and 16 by default.
  final Widget? leading;

  /// `primary` on the page, `secondary` on a surface (a card, a sheet).
  final FlInputVariant variant;

  @override
  State<FlSegmentedField> createState() => _FlSegmentedFieldState();
}

class _FlSegmentedFieldState extends State<FlSegmentedField> with TextInputClient {
  late Map<FlDateSegment, int?> _values = {for (final s in widget.segments) s: widget.values[s]};
  late final Map<FlDateSegment, FocusNode> _nodes = {
    for (final s in FlDateSegment.values)
      s: FocusNode(debugLabel: 'FlSegmentedField ${s.name}', onKeyEvent: (node, event) => _key(s, event)),
  };
  final Map<FlDateSegment, GlobalKey> _keys = {for (final s in FlDateSegment.values) s: GlobalKey()};

  /// The digits typed into the current segment, before it is full.
  String _typed = '';
  FlDateSegment? _focused;

  /// The last value emitted, to compare it with the one the app passes back.
  late Map<FlDateSegment, int>? _emitted = widget.values.isEmpty ? null : widget.values;
  TextInputConnection? _connection;

  bool get _enabled => widget.onChanged != null;

  bool get _touch => switch (FlScope.of(context).platform) {
    TargetPlatform.iOS || TargetPlatform.android => true,
    _ => false,
  };

  /// The last day of the month when the month is known (of February, in a
  /// leap year when the year is one), 31 otherwise, like React Aria.
  int get _lastDay {
    final month = _values[FlDateSegment.month];
    if (month == null) return 31;
    return DateTime(_values[FlDateSegment.year] ?? 2000, month + 1, 0).day;
  }

  _Range _range(FlDateSegment segment) => switch (segment) {
    FlDateSegment.day => (min: 1, max: _lastDay, width: 2),
    FlDateSegment.month => (min: 1, max: 12, width: 2),
    FlDateSegment.year => (min: 1, max: 9999, width: 4),
    FlDateSegment.hour => widget.strings.hour12 ? (min: 1, max: 12, width: 2) : (min: 0, max: 23, width: 2),
    FlDateSegment.minute => (min: 0, max: 59, width: 2),
    FlDateSegment.dayPeriod => (min: 0, max: 1, width: 1),
  };

  @override
  void initState() {
    super.initState();
    for (final entry in _nodes.entries) {
      entry.value.addListener(() => _focusChanged(entry.key));
    }
  }

  @override
  void didUpdateWidget(FlSegmentedField oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.error case final error? when oldWidget.error == null && MediaQuery.supportsAnnounceOf(context)) {
      SemanticsService.sendAnnouncement(View.of(context), error, Directionality.of(context));
    }
    if (!mapEquals(widget.values, oldWidget.values) || !listEquals(widget.segments, oldWidget.segments)) {
      _adopt();
    }
    if (!_enabled) {
      for (final node in _nodes.values) {
        node.unfocus();
      }
    }
  }

  /// Takes up the app's value, except an input in progress when the app has
  /// no value.
  void _adopt() {
    final values = widget.values;
    if (values.isEmpty && _emitted == null) {
      _values = {for (final s in widget.segments) s: _values[s]};
      return;
    }
    _values = {for (final s in widget.segments) s: values[s]};
    _emitted = values.isEmpty ? null : values;
    _typed = '';
  }

  @override
  void dispose() {
    _connection?.close();
    for (final node in _nodes.values) {
      node.dispose();
    }
    super.dispose();
  }

  void _focusChanged(FlDateSegment segment) {
    final focused = _nodes[segment]!.hasFocus;
    if (focused) {
      final settled = _typed.isNotEmpty;
      setState(() {
        _focused = segment;
        _typed = '';
      });
      if (settled) _report();
      if (_touch) _openKeyboard();
      return;
    }
    if (_focused != segment) return;
    setState(() {
      _focused = null;
      _typed = '';
    });
    _report();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (!mounted || _focused != null) return;
      _connection?.close();
      _connection = null;
    });
  }

  void _openKeyboard() {
    final connection = _connection;
    if (connection == null || !connection.attached) {
      _connection = TextInput.attach(
        this,
        const TextInputConfiguration(
          inputType: TextInputType.number,
          inputAction: TextInputAction.done,
          enableSuggestions: false,
          autocorrect: false,
        ),
      )..setEditingState(_baseline);
    }
    _connection!.show();
  }

  // -- Input ----------------------------------------------------------------

  void _set(FlDateSegment segment, int? value) {
    setState(() => _values[segment] = value);
    _report();
  }

  /// Emits the value when all segments are filled, and `null` when a
  /// segment is emptied after a value was emitted.
  void _report() {
    final onChanged = widget.onChanged;
    if (onChanged == null || _typed.isNotEmpty) return;
    if (_values.values.any((v) => v == null)) {
      if (_emitted != null) {
        _emitted = null;
        onChanged(null);
      }
      return;
    }
    var full = {for (final entry in _values.entries) entry.key: entry.value!};
    full = widget.complete?.call(full) ?? full;
    // The field shows the clamped value, even if already emitted.
    setState(() => _values = {...full});
    if (mapEquals(full, _emitted)) return;
    _emitted = full;
    onChanged(full);
    // The screen shows only the app's value: refused (the app does not pass
    // it back), the input reverts to the app's.
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (!mounted || mapEquals(widget.values, full)) return;
      setState(() {
        _values = {for (final s in widget.segments) s: widget.values[s]};
        _emitted = widget.values.isEmpty ? null : widget.values;
      });
    });
  }

  /// Types [digit] into [segment], and returns the segment that receives the
  /// next digit: the next one when this one is full.
  FlDateSegment _type(FlDateSegment segment, int digit) {
    if (segment == FlDateSegment.dayPeriod) return segment;
    final range = _range(segment);
    var typed = '$_typed$digit';
    if (typed.length > range.width || int.parse(typed) > range.max) {
      typed = '$digit';
    }
    var value = int.parse(typed);
    final full = typed.length >= range.width || value * 10 > range.max;
    // "00" on a 12-hour clock is midnight or noon: 12.
    if (full && value == 0 && segment == FlDateSegment.hour && widget.strings.hour12) {
      value = 12;
    }
    final valid = value >= range.min && value <= range.max;
    // A full segment is settled; otherwise the input continues, and the
    // value is sent only at the next segment.
    _typed = full ? '' : typed;
    if (valid) _placePeriod(segment);
    _set(segment, valid ? value : null);
    if (!full) return segment;
    return _move(segment, 1) ?? segment;
  }

  /// An hour set on a 12-hour clock without morning or afternoon takes the
  /// morning, which the screen already shows as pending.
  void _placePeriod(FlDateSegment segment) {
    if (segment == FlDateSegment.hour &&
        widget.strings.hour12 &&
        _values.containsKey(FlDateSegment.dayPeriod) &&
        _values[FlDateSegment.dayPeriod] == null) {
      _values[FlDateSegment.dayPeriod] = 0;
    }
  }

  /// The morning becomes the afternoon and vice versa: the tap on the
  /// segment, since the number pad has no letters.
  void _togglePeriod() {
    const segment = FlDateSegment.dayPeriod;
    _typed = '';
    final node = _nodes[segment]!;
    if (!node.hasFocus) node.requestFocus();
    _set(segment, 1 - (_values[segment] ?? 0));
  }

  void _erase(FlDateSegment segment) {
    final value = _values[segment];
    if (value == null && _typed.isEmpty) {
      _move(segment, -1);
      return;
    }
    if (segment == FlDateSegment.dayPeriod) {
      _set(segment, null);
      return;
    }
    final digits = _typed.isNotEmpty ? _typed : '$value';
    _typed = digits.substring(0, digits.length - 1);
    final rest = int.tryParse(_typed);
    final range = _range(segment);
    _set(segment, rest != null && rest >= range.min ? rest : null);
  }

  /// The value of [segment] [step] notches further: it wraps around, except
  /// the year; an empty segment starts from the beginning, or the current year.
  int _next(FlDateSegment segment, int step) {
    final range = _range(segment);
    final value = _values[segment];
    if (value == null) {
      return segment == FlDateSegment.year ? DateTime.now().year : (step > 0 ? range.min : range.max);
    }
    if (segment == FlDateSegment.year) {
      return (value + step).clamp(range.min, range.max);
    }
    final span = range.max - range.min + 1;
    return range.min + (value - range.min + step) % span;
  }

  void _step(FlDateSegment segment, int step) {
    _typed = '';
    _placePeriod(segment);
    _set(segment, _next(segment, step));
  }

  /// The focus to the segment [step] further, if there is one.
  FlDateSegment? _move(FlDateSegment from, int step) {
    final at = widget.segments.indexOf(from) + step;
    if (at < 0 || at >= widget.segments.length) return null;
    final to = widget.segments[at];
    _nodes[to]!.requestFocus();
    return to;
  }

  /// The focus to the neighboring segment on screen, to the right when
  /// [right] is true: the hour and the minute read left to right even in a
  /// right-to-left page, so the order of the segments is not enough.
  void _moveOnScreen(FlDateSegment from, {required bool right}) {
    double? x(FlDateSegment segment) {
      final box = _keys[segment]!.currentContext?.findRenderObject() as RenderBox?;
      return box == null || !box.hasSize ? null : box.localToGlobal(box.size.center(Offset.zero)).dx;
    }

    final here = x(from);
    if (here == null) return;
    FlDateSegment? best;
    double? bestX;
    for (final segment in widget.segments) {
      final there = x(segment);
      if (there == null || segment == from || (right ? there <= here : there >= here)) {
        continue;
      }
      if (bestX == null || (right ? there < bestX : there > bestX)) {
        best = segment;
        bestX = there;
      }
    }
    if (best != null) _nodes[best]!.requestFocus();
  }

  KeyEventResult _key(FlDateSegment segment, KeyEvent event) {
    if (event is KeyUpEvent || !_enabled) return KeyEventResult.ignored;
    final key = event.logicalKey;
    switch (key) {
      case LogicalKeyboardKey.arrowUp:
        _step(segment, 1);
      case LogicalKeyboardKey.arrowDown:
        _step(segment, -1);
      case LogicalKeyboardKey.arrowRight:
        _moveOnScreen(segment, right: true);
      case LogicalKeyboardKey.arrowLeft:
        _moveOnScreen(segment, right: false);
      case LogicalKeyboardKey.backspace || LogicalKeyboardKey.delete:
        _erase(segment);
      default:
        final character = event.character;
        if (character == null || character.isEmpty) {
          return KeyEventResult.ignored;
        }
        if (segment == FlDateSegment.dayPeriod) {
          final letter = character.toLowerCase();
          if (letter != 'a' && letter != 'p') return KeyEventResult.ignored;
          _set(segment, letter == 'a' ? 0 : 1);
          return KeyEventResult.handled;
        }
        final digit = widget.strings.digitOf(character);
        if (digit == null) return KeyEventResult.ignored;
        _type(segment, digit);
    }
    return KeyEventResult.handled;
  }

  // -- Virtual keyboard -----------------------------------------------------

  @override
  TextEditingValue get currentTextEditingValue => _baseline;

  @override
  AutofillScope? get currentAutofillScope => null;

  @override
  void updateEditingValue(TextEditingValue value) {
    final segment = _focused;
    if (segment == null || value.text == _keep) return;
    if (value.text.length < _keep.length) {
      _erase(segment);
    } else {
      // The focus moves from one segment to another after this frame: pasted
      // text follows the current segment itself.
      var current = segment;
      for (final character in value.text.replaceAll('\u200b', '').split('')) {
        final digit = widget.strings.digitOf(character);
        if (digit != null) current = _type(current, digit);
      }
    }
    _connection?.setEditingState(_baseline);
  }

  @override
  void performAction(TextInputAction action) => _nodes[_focused]?.unfocus();

  @override
  void performPrivateCommand(String action, Map<String, dynamic> data) {}

  @override
  void updateFloatingCursor(RawFloatingCursorPoint point) {}

  @override
  void showAutocorrectionPromptRect(int start, int end) {}

  @override
  void connectionClosed() {
    _connection = null;
    _nodes[_focused]?.unfocus();
  }

  // -- Rendering ------------------------------------------------------------

  /// The first empty segment, otherwise the first.
  FlDateSegment get _entry =>
      widget.segments.firstWhere((s) => _values[s] == null, orElse: () => widget.segments.first);

  void _focus(FlDateSegment segment) {
    if (!_enabled) return;
    final node = _nodes[segment]!;
    if (node.hasFocus) {
      if (_touch) _openKeyboard();
    } else {
      node.requestFocus();
    }
  }

  String _display(FlDateSegment segment) {
    final strings = widget.strings;
    final value = _values[segment];
    if (segment == FlDateSegment.dayPeriod) {
      return strings.dayPeriods[value ?? 0];
    }
    if (_focused == segment && _typed.isNotEmpty) {
      return strings.number(int.parse(_typed));
    }
    if (value == null) return strings.placeholders[segment] ?? '--';
    return switch (segment) {
      FlDateSegment.year => strings.number(value),
      FlDateSegment.hour when strings.hour12 => strings.number(value),
      _ => strings.number(value, width: 2),
    };
  }

  String _valueText(FlDateSegment segment, int? value) {
    final strings = widget.strings;
    if (value == null) return strings.empty;
    return switch (segment) {
      FlDateSegment.month => '${strings.number(value)} – ${strings.months[value - 1]}',
      FlDateSegment.dayPeriod => strings.dayPeriods[value],
      _ => strings.number(value),
    };
  }

  Widget _segment(FlTheme theme, TextStyle style, FlDateSegment segment, bool invalid) {
    final colors = theme.colors;
    final focused = _focused == segment;
    final empty = _values[segment] == null && !(focused && _typed.isNotEmpty);
    final Color ink;
    final Color? fill;
    if (focused) {
      ink = invalid ? colors.onDangerSoft : colors.onAccentSoft;
      fill = invalid ? colors.dangerSoft : colors.accentSoft;
    } else {
      // Izydesk's invalid field keeps its text: the outline and the message
      // carry the state.
      ink = empty ? colors.fieldPlaceholder : colors.onSurface;
      fill = null;
    }
    return Semantics(
      label: widget.strings.names[segment],
      value: _valueText(segment, _values[segment]),
      increasedValue: _enabled ? _valueText(segment, _next(segment, 1)) : null,
      decreasedValue: _enabled ? _valueText(segment, _next(segment, -1)) : null,
      onIncrease: _enabled ? () => _step(segment, 1) : null,
      onDecrease: _enabled ? () => _step(segment, -1) : null,
      focusable: _enabled,
      focused: focused,
      enabled: _enabled,
      excludeSemantics: true,
      child: Focus(
        key: _keys[segment],
        focusNode: _nodes[segment],
        canRequestFocus: _enabled,
        autofocus: widget.autofocus && segment == _entry,
        child: GestureDetector(
          behavior: HitTestBehavior.opaque,
          // For the screen reader, the segment is adjusted through the
          // increase and decrease gestures; the tap that opens the keyboard
          // is the field's, a target of its size.
          excludeFromSemantics: true,
          onTap: () => segment == FlDateSegment.dayPeriod && _enabled ? _togglePeriod() : _focus(segment),
          child: DecoratedBox(
            decoration: BoxDecoration(color: fill, borderRadius: BorderRadius.circular(theme.shape.radiusXs)),
            child: Padding(
              padding: EdgeInsetsDirectional.symmetric(horizontal: _segmentPadding),
              // Yoga rounds a text's box up to the next pixel: the following
              // segments are placed after it.
              child: FlPixelSnap(
                child: Text(_display(segment), style: style.copyWith(color: ink)),
              ),
            ),
          ),
        ),
      ),
    );
  }

  // Izydesk's fields write `09 / 03 / 2031` and `07 : 30`: the separator
  // keeps some air on each side.
  Widget _literal(FlTheme theme, TextStyle style, String text) => ExcludeSemantics(
    child: Padding(
      padding: EdgeInsetsDirectional.symmetric(horizontal: theme.space.xs / 2),
      child: FlPixelSnap(child: Text(text, style: style)),
    ),
  );

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final colors = theme.colors;
    final strings = widget.strings;
    final announces = MediaQuery.supportsAnnounceOf(context);
    final error = widget.error;
    final description = widget.description;
    final invalid = error != null;
    final disabled = !_enabled;
    final described = error == null ? description : (announces ? error : null);
    final style = DefaultTextStyle.of(context).style.merge(theme.typography.resolve(FlTextRole.textSm));
    // The separators take the color of the value they split: placeholder
    // while the field is empty (`jj / mm / aaaa`), text once it is not.
    final blank = widget.segments.every((segment) => _values[segment] == null);
    final literal = style.copyWith(color: blank ? colors.fieldPlaceholder : colors.onSurface);
    final segments = widget.segments;
    final parts = <Widget>[];
    final time = <Widget>[];
    for (final (i, segment) in segments.indexed) {
      final widgetFor = _segment(theme, style, segment, invalid);
      switch (segment) {
        case FlDateSegment.hour || FlDateSegment.minute:
          if (time.isNotEmpty) {
            time.add(_literal(theme, literal, strings.timeSeparator));
          }
          time.add(widgetFor);
        case FlDateSegment.dayPeriod:
          parts
            ..add(SizedBox(width: theme.space.xs))
            ..add(widgetFor);
        default:
          if (i > 0) parts.add(_literal(theme, literal, strings.separator));
          parts.add(widgetFor);
      }
      // The hour and the minute read left to right, even in Arabic, like
      // the isolate that React Aria sets.
      if (segment == FlDateSegment.minute) {
        parts.add(
          Directionality(
            textDirection: TextDirection.ltr,
            child: Row(mainAxisSize: MainAxisSize.min, children: [...time]),
          ),
        );
        time.clear();
      }
    }
    final role = theme.typography.style(FlTextRole.textSm);
    final lineHeight = MediaQuery.textScalerOf(context).scale(role.size) * role.lineHeight / role.size;
    final focused = _focused != null;
    // The field's node is the target's: at the height of a compact control
    // (36), it keeps its 44 or 48.
    final frame = FlHitTarget(
      child: Semantics(
        explicitChildNodes: true,
        label: widget.label,
        hint: described,
        isRequired: widget.required ? true : null,
        validationResult: invalid ? SemanticsValidationResult.invalid : SemanticsValidationResult.none,
        enabled: !disabled,
        onTap: disabled ? null : () => _focus(_entry),
        child: GestureDetector(
          behavior: HitTestBehavior.opaque,
          excludeFromSemantics: true,
          onTap: () => _focus(_entry),
          child: Opacity(
            opacity: disabled ? theme.size.disabledOpacity : 1,
            // Refused (an error appears), the field shakes once.
            child: FlShake(
              active: invalid,
              child: FlInputFrame(
                variant: widget.variant,
                outline: invalid ? colors.danger : null,
                focused: focused,
                child: ConstrainedBox(
                  constraints: BoxConstraints(minHeight: theme.controlHeight),
                  child: Padding(
                    padding: EdgeInsetsDirectional.symmetric(horizontal: theme.space.md, vertical: theme.space.xs),
                    child: Row(
                      spacing: theme.space.sm,
                      children: [
                        if (widget.leading case final leading?)
                          ExcludeSemantics(
                            child: IconTheme.merge(
                              data: IconThemeData(
                                color: colors.onSurfaceMuted,
                                size: MediaQuery.textScalerOf(context)
                                    .clamp(maxScaleFactor: theme.size.iconMaxScale)
                                    .scale(_iconSize),
                              ),
                              child: DefaultTextStyle.merge(
                                style: style.copyWith(color: colors.onSurfaceMuted),
                                child: leading,
                              ),
                            ),
                          ),
                        Expanded(
                          child: Align(
                            alignment: AlignmentDirectional.centerStart,
                            child: ConstrainedBox(
                              constraints: BoxConstraints(minHeight: lineHeight),
                              // A row shorter than the line is centered in the line:
                              // placed at the top, it rose by a point and a half.
                              child: Align(
                                alignment: AlignmentDirectional.centerStart,
                                widthFactor: 1,
                                child: Wrap(crossAxisAlignment: WrapCrossAlignment.center, children: parts),
                              ),
                            ),
                          ),
                        ),
                        if (widget.icon case final icon?)
                          ExcludeSemantics(
                            child: FlIcon(
                              icon,
                              size: MediaQuery.textScalerOf(context)
                                  .clamp(maxScaleFactor: theme.size.iconMaxScale)
                                  .scale(_iconSize),
                              color: colors.onSurfaceMuted,
                            ),
                          ),
                      ],
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
    return TapRegion(
      onTapOutside: focused ? (_) => _nodes[_focused]?.unfocus() : null,
      child: FlFieldScope(
        invalid: invalid,
        disabled: disabled,
        required: widget.required,
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          // Izydesk's `.field` gap, like FlTextField.
          spacing: flFieldGap(theme),
          children: [
            Align(
              alignment: AlignmentDirectional.centerStart,
              child: ExcludeSemantics(child: FlLabel(widget.label)),
            ),
            frame,
            if (description != null && error == null) ExcludeSemantics(child: FlDescription(description)),
            if (error != null)
              announces
                  ? ExcludeSemantics(child: FlFieldError(error))
                  : Align(
                      alignment: AlignmentDirectional.centerStart,
                      child: Semantics(container: true, child: FlFieldError(error)),
                    ),
          ],
        ),
      ),
    );
  }
}

date_field/fl_date_field_strings.dart

import 'package:flutter/widgets.dart';

/// A segment of a date or time field, what it counts.
enum FlDateSegment {
  /// The day of the month, from 1 to 31.
  day,

  /// The month, from 1 to 12.
  month,

  /// The year, from 1 to 9999.
  year,

  /// The hour, from 0 to 23, or from 1 to 12 with [dayPeriod].
  hour,

  /// The minute, from 0 to 59.
  minute,

  /// The morning (0) or afternoon (1) of a 12-hour time.
  dayPeriod,
}

/// The texts and rules of a date or time field, by key then by language:
/// the same table as the `l10n` of the `date-field` manifest.
/// `dateFieldOrder` is the order of the day (`d`), the month (`m`) and the
/// year (`y`); `dateFieldPlaceholders` and `dateFieldNames` are
/// comma-separated lists (day, month, year; day, month, year, hour, minute,
/// morning or afternoon); `timeFieldHourCycle` is 12 or 24. For another
/// language, an app passes a table with the same keys to
/// `FlDateFieldStrings.fromTable`.
const Map<String, Map<String, String>> flDateFieldL10n = {
  'dateFieldOrder': {'en': 'mdy', 'fr': 'dmy', 'ar': 'dmy'},
  'dateFieldSeparator': {'en': '/', 'fr': '/', 'ar': '/'},
  'dateFieldPlaceholders': {'en': 'dd,mm,yyyy', 'fr': 'jj,mm,aaaa', 'ar': 'يوم,شهر,سنة'},
  'dateFieldNames': {
    'en': 'day,month,year,hour,minute,AM/PM',
    'fr': 'jour,mois,année,heure,minute,AM/PM',
    'ar': 'اليوم,الشهر,السنة,الساعة,الدقيقة,ص/م',
  },
  'dateFieldEmpty': {'en': 'empty', 'fr': 'vide', 'ar': 'فارغ'},
  'dateFieldMonths': {
    'en': 'January,February,March,April,May,June,July,August,September,October,November,December',
    'fr': 'janvier,février,mars,avril,mai,juin,juillet,août,septembre,octobre,novembre,décembre',
    'ar': 'يناير,فبراير,مارس,أبريل,مايو,يونيو,يوليو,أغسطس,سبتمبر,أكتوبر,نوفمبر,ديسمبر',
  },
  'dateFieldDigits': {'en': '0123456789', 'fr': '0123456789', 'ar': '٠١٢٣٤٥٦٧٨٩'},
  'timeFieldHourCycle': {'en': '12', 'fr': '24', 'ar': '12'},
  'timeFieldDayPeriods': {'en': 'AM,PM', 'fr': 'AM,PM', 'ar': 'ص,م'},
  'timeFieldSeparator': {'en': ':', 'fr': ':', 'ar': ':'},
};

/// What a date or time field writes and reads in a language: the order of
/// the segments, their separators, their placeholder texts, their names for
/// the screen reader, the digits, and the 12- or 24-hour clock.
///
/// `registry` has no `intl`. Without a given [FlDateFieldStrings], the
/// field reads [flDateFieldL10n] in the language of the nearest
/// `Localizations`: English, French or Arabic. Another language fails with
/// an assertion in debug mode (English in release): the app then passes its
/// texts through [FlDateFieldStrings.fromTable]. With `intl`, the order is
/// read from the pattern of `DateFormat.yMd(locale).pattern` (the first
/// letter of each field), the 12-hour clock from `DateFormat.jm(locale).pattern`
/// (an `a`), and the digits from `NumberFormat`.
final class FlDateFieldStrings {
  /// Texts given by the app. [order] arranges [FlDateSegment.day],
  /// [FlDateSegment.month] and [FlDateSegment.year].
  FlDateFieldStrings({
    required this.order,
    required this.separator,
    required this.placeholders,
    required this.names,
    required this.empty,
    required this.months,
    required this.digits,
    required this.hour12,
    required this.dayPeriods,
    required this.timeSeparator,
  }) : assert(order.length == 3 && order.toSet().length == 3, 'day, month and year, once each'),
       assert(names.length == FlDateSegment.values.length, 'one name per segment'),
       assert(months.length == 12, 'twelve months'),
       assert(digits.length == 10, 'ten digits'),
       assert(dayPeriods.length == 2, 'morning and afternoon');

  /// The texts of a language, by key of [flDateFieldL10n]. Throws a
  /// [FormatException] that names the missing or malformed key.
  factory FlDateFieldStrings.fromTable(Map<String, String> table) {
    String t(String key) => table[key] ?? (throw FormatException('key $key missing from the date field table'));
    List<String> list(String key, int length) {
      final items = t(key).split(',');
      if (items.length != length) {
        throw FormatException('$key: $length comma-separated items expected, ${items.length} read');
      }
      return items;
    }

    const letters = {'d': FlDateSegment.day, 'm': FlDateSegment.month, 'y': FlDateSegment.year};
    final order = [for (final letter in t('dateFieldOrder').split('')) letters[letter]];
    if (order.length != 3 || order.contains(null) || order.toSet().length != 3) {
      throw FormatException('dateFieldOrder: d, m and y once each expected, "${t('dateFieldOrder')}" read');
    }
    final cycle = t('timeFieldHourCycle');
    if (cycle != '12' && cycle != '24') {
      throw FormatException('timeFieldHourCycle: 12 or 24 expected, "$cycle" read');
    }
    if (t('dateFieldDigits').length != 10) {
      throw const FormatException('dateFieldDigits: ten digits expected');
    }
    final placeholders = list('dateFieldPlaceholders', 3);
    final names = list('dateFieldNames', FlDateSegment.values.length);
    return FlDateFieldStrings(
      order: [for (final segment in order) segment!],
      separator: t('dateFieldSeparator'),
      placeholders: {
        FlDateSegment.day: placeholders[0],
        FlDateSegment.month: placeholders[1],
        FlDateSegment.year: placeholders[2],
      },
      names: {for (final (i, segment) in FlDateSegment.values.indexed) segment: names[i]},
      empty: t('dateFieldEmpty'),
      months: list('dateFieldMonths', 12),
      digits: t('dateFieldDigits'),
      hour12: cycle == '12',
      dayPeriods: list('timeFieldDayPeriods', 2),
      timeSeparator: t('timeFieldSeparator'),
    );
  }

  /// The texts of [flDateFieldL10n] in [language] (`en`, `fr` or `ar`),
  /// English for another language.
  factory FlDateFieldStrings.language(String language) {
    final code = languages.contains(language) ? language : 'en';
    return FlDateFieldStrings.fromTable({for (final entry in flDateFieldL10n.entries) entry.key: entry.value[code]!});
  }

  /// The texts in the language of the nearest `Localizations`. A language
  /// missing from [flDateFieldL10n] fails with an assertion in debug mode,
  /// and takes English in release.
  factory FlDateFieldStrings.of(BuildContext context) {
    final language = Localizations.maybeLocaleOf(context)?.languageCode ?? 'en';
    assert(
      languages.contains(language),
      'FlDateField: no texts for "$language" ($languages only). Pass strings, '
      'built by FlDateFieldStrings.fromTable.',
    );
    return FlDateFieldStrings.language(language);
  }

  /// The languages of [flDateFieldL10n].
  static final Set<String> languages = flDateFieldL10n.values.first.keys.toSet();

  /// The order of the day, the month and the year (`[month, day, year]` in
  /// American English).
  final List<FlDateSegment> order;

  /// What separates the segments of a date ("/").
  final String separator;

  /// The text of an empty date segment ("dd", "mm", "yyyy").
  final Map<FlDateSegment, String> placeholders;

  /// The name of each segment for the screen reader ("day").
  final Map<FlDateSegment, String> names;

  /// The spoken value of an empty segment.
  final String empty;

  /// The names of the months, from January to December, read with the month number.
  final List<String> months;

  /// The digits from 0 to 9.
  final String digits;

  /// True: the hour goes from 1 to 12, followed by the morning or the afternoon.
  final bool hour12;

  /// The morning and the afternoon ("AM", "PM").
  final List<String> dayPeriods;

  /// What separates the hour from the minute (":").
  final String timeSeparator;

  /// [value] written with [digits], padded with zeros up to [width] digits.
  String number(int value, {int width = 1}) =>
      value.toString().padLeft(width, '0').split('').map((c) => digits[int.parse(c)]).join();

  /// The digit from 0 to 9 that [character] writes, in Latin digits or in
  /// those of [digits]; null for another character.
  int? digitOf(String character) {
    final latin = int.tryParse(character);
    if (latin != null && character.length == 1) return latin;
    final local = digits.indexOf(character);
    return local < 0 ? null : local;
  }
}

Arrows to move, Enter to open, Escape to close.