ashlarui

Date Picker

A button that shows a date and opens a calendar, with presets.

Date Picker, as it looks in an app Date Picker, as it looks in an app, dark
import 'package:flutter/widgets.dart';

import 'ui/calendar.dart';
import 'ui/date_picker.dart';

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

  @override
  State<DatePickerDemo> createState() => _DatePickerDemoState();
}

class _DatePickerDemoState extends State<DatePickerDemo> {
  DateTime? due;
  DateRange? stay;

  @override
  Widget build(BuildContext context) => Row(
    mainAxisSize: MainAxisSize.min,
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      SizedBox(
        width: 220,
        child: DatePicker(value: due, semanticLabel: 'Due date', onChanged: (d) => setState(() => due = d)),
      ),
      const SizedBox(width: 16),
      SizedBox(
        width: 300,
        child: DatePicker.range(range: stay, semanticLabel: 'Stay', onRangeChanged: (r) => setState(() => stay = r)),
      ),
    ],
  );
}

Try its options

Each option its constructor takes, to change and see; the code below it is what you chose, to copy.

Date Picker with controls, as it looks in an app Date Picker with controls, as it looks in an app, dark

Add it

In your Flutter project:

$ ashlar add date-picker

It writes theme.dart, palette.dart, group.dart, pressable.dart, popover.dart, button.dart, select.dart, calendar.dart and date_picker.dart into lib/ui, keeping any you already have, and adds ashlar_icons to pubspec.yaml. It needs pressable, popover, calendar, select and ashlar_icons, which come with it.

Examples

Other ways to use it, each running live, with its code.

A span

Check-in and check-out.

A span, as it looks in an app A span, as it looks in an app, dark
// A span: Check-in and check-out.
import 'package:flutter/widgets.dart';

import 'ui/calendar.dart';
import 'ui/date_picker.dart';

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

  @override
  State<DatePickerRangeExample> createState() => _DatePickerRangeExampleState();
}

class _DatePickerRangeExampleState extends State<DatePickerRangeExample> {
  DateRange? range;

  @override
  Widget build(BuildContext context) => SizedBox(
    width: 300,
    child: DatePicker.range(
      range: range,
      placeholder: 'Pick your stay',
      onRangeChanged: (r) => setState(() => range = r),
    ),
  );
}

With presets

Today, tomorrow, in a week.

With presets, as it looks in an app With presets, as it looks in an app, dark
// With presets: Today, tomorrow, in a week.
import 'package:flutter/widgets.dart';

import 'ui/date_picker.dart';

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

  @override
  State<DatePickerPresetsExample> createState() => _DatePickerPresetsExampleState();
}

class _DatePickerPresetsExampleState extends State<DatePickerPresetsExample> {
  DateTime? day;

  @override
  Widget build(BuildContext context) => SizedBox(
    width: 260,
    child: DatePicker(
      value: day,
      onChanged: (d) => setState(() => day = d),
      presets: [
        ('Today', DateTime(2026, 10, 13)),
        ('Tomorrow', DateTime(2026, 10, 14)),
        ('In a week', DateTime(2026, 10, 20)),
      ],
    ),
  );
}

API

Read from the source above: each parameter with its type, its default, and what it is for.

DatePickerclass

A button that shows a date and opens a Calendar to change it. DatePicker.range picks a start and an end, and closes once both are chosen.

DatePicker(…)

Parameter Type Default What it is for
valuerequired DateTime? The day picked, or null.
onChangedrequired ValueChanged<DateTime>? Told each new value as it changes.
placeholder String 'Pick a date' Faint text shown while it is empty.
firstDate DateTime? The earliest day that can be picked.
lastDate DateTime? The latest day that can be picked.
isDisabled bool Function(DateTime day)? Days that cannot be picked: weekends, booked days.
semanticLabel String? What the date is for, read out before it: 'Due date'.
presets List<(String, DateTime)> const [] Dates a tap away, offered above the calendar: ('Tomorrow', tomorrow).

DatePicker.range(…)

Parameter Type Default What it is for
rangerequired DateRange? The days picked, for a picker that takes a span.
onRangeChangedrequired ValueChanged<DateRange>? Told the new range whenever either end changes.
placeholder String 'Pick dates' Faint text shown while it is empty.
firstDate DateTime? The earliest day that can be picked.
lastDate DateTime? The latest day that can be picked.
isDisabled bool Function(DateTime day)? Days that cannot be picked: weekends, booked days.
semanticLabel String? What the date is for, read out before it: 'Due date'.

Source

What lands in your app. It is yours from then on: change it as you would your own code, and ashlar add will not write over it.

lib/ui/date_picker.dart 217 lines
// Ashlar UI: Date Picker. Copied into your app by `ashlar add date-picker`; it is yours to
// change.

import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';

import 'calendar.dart';
import 'popover.dart';
import 'pressable.dart';
import 'select.dart';
import 'theme.dart';

/// A button that shows a date and opens a Calendar to change it. [DatePicker.range] picks
/// a start and an end, and closes once both are chosen.
///
/// ```dart
/// DatePicker(value: due, onChanged: (d) => setState(() => due = d))
/// DatePicker.range(range: stay, onRangeChanged: (r) => setState(() => stay = r))
/// ```
class DatePicker extends StatefulWidget {
  const DatePicker({
    super.key,
    required this.value,
    required this.onChanged,
    this.placeholder = 'Pick a date',
    this.firstDate,
    this.lastDate,
    this.isDisabled,
    this.semanticLabel,
    this.presets = const [],
  }) : range = null,
       onRangeChanged = null;

  const DatePicker.range({
    super.key,
    required this.range,
    required this.onRangeChanged,
    this.placeholder = 'Pick dates',
    this.firstDate,
    this.lastDate,
    this.isDisabled,
    this.semanticLabel,
  }) : presets = const [],
       value = null,
       onChanged = null;

  /// The day picked, or null.
  final DateTime? value;

  /// Told each new value as it changes.
  final ValueChanged<DateTime>? onChanged;

  /// The days picked, for a picker that takes a span.
  final DateRange? range;

  /// Told the new range whenever either end changes.
  final ValueChanged<DateRange>? onRangeChanged;

  /// Faint text shown while it is empty.
  final String placeholder;

  /// The earliest day that can be picked.
  final DateTime? firstDate;

  /// The latest day that can be picked.
  final DateTime? lastDate;

  /// Days that cannot be picked: weekends, booked days.
  final bool Function(DateTime day)? isDisabled;

  /// What the date is for, read out before it: 'Due date'.
  final String? semanticLabel;

  /// Dates a tap away, offered above the calendar: ('Tomorrow', tomorrow).
  final List<(String, DateTime)> presets;

  @override
  State<DatePicker> createState() => _DatePickerState();
}

class _DatePickerState extends State<DatePicker> {
  final _menu = MenuController();
  final _focus = FocusNode();

  @override
  void dispose() {
    _focus.dispose();
    super.dispose();
  }

  bool get _isRange =>
      widget.onRangeChanged != null ||
      (widget.onChanged == null && widget.range != null);
  bool get _enabled =>
      widget.onChanged != null || widget.onRangeChanged != null;

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    final names = DateNames.of(context);
    final r = widget.range;
    final shown = _isRange
        ? (r == null
              ? null
              : (r.end == null
                    ? names.date(r.start)
                    : '${names.date(r.start)} to ${names.date(r.end!)}'))
        : (widget.value == null ? null : names.date(widget.value!));
    return RawMenuAnchor(
      controller: _menu,
      childFocusNode: _focus,
      onClose: _focus.requestFocus,
      overlayBuilder: (context, info) => PopoverPanel(
        info: info,
        // Scrolls when the room by the trigger is shorter than the calendar, and is as wide
        // as the calendar: a scroll view alone takes all the width it is given.
        child: IntrinsicWidth(
          child: SingleChildScrollView(
            padding: const EdgeInsets.all(12),
            child: _isRange
                ? Calendar.range(
                    autofocus: true,
                    range: widget.range,
                    firstDate: widget.firstDate,
                    lastDate: widget.lastDate,
                    isDisabled: widget.isDisabled,
                    onRangeChanged: (v) {
                      widget.onRangeChanged?.call(v);
                      if (v.end != null) _menu.close();
                    },
                  )
                : Column(
                    mainAxisSize: MainAxisSize.min,
                    crossAxisAlignment: CrossAxisAlignment.stretch,
                    children: [
                      if (widget.presets.isNotEmpty) ...[
                        Select<int>(
                          value: null,
                          size: SelectSize.sm,
                          placeholder: 'Pick a preset',
                          options: [
                            for (final (i, p) in widget.presets.indexed)
                              SelectOption(i, p.$1),
                          ],
                          onChanged: (i) {
                            widget.onChanged?.call(widget.presets[i].$2);
                            _menu.close();
                          },
                        ),
                        const SizedBox(height: 8),
                      ],
                      Calendar(
                        autofocus: widget.presets.isEmpty,
                        selected: widget.value,
                        firstDate: widget.firstDate,
                        lastDate: widget.lastDate,
                        isDisabled: widget.isDisabled,
                        onSelected: (d) {
                          widget.onChanged?.call(d);
                          _menu.close();
                        },
                      ),
                    ],
                  ),
          ),
        ),
      ),
      child: Pressable(
        focusNode: _focus,
        onPressed: _enabled
            ? () => _menu.isOpen ? _menu.close() : _menu.open()
            : null,
        semanticLabel: widget.semanticLabel,
        semanticValue: widget.semanticLabel == null
            ? null
            : (shown ?? widget.placeholder),
        builder: (context, state) => Opacity(
          opacity: state.enabled ? 1 : .5,
          child: AnimatedContainer(
            duration: t.duration,
            // A height at least: larger text makes it taller rather than being cut off.
            constraints: BoxConstraints(minHeight: t.control(36)),
            padding: const EdgeInsets.symmetric(horizontal: 12),
            decoration: BoxDecoration(
              color: state.hovered ? c.accent : c.background,
              borderRadius: BorderRadius.circular(t.radiusMd),
              border: Border.all(color: state.focused ? c.ring : c.input),
              boxShadow: [if (state.focused) ...t.focusRing, ...t.shadowXs],
            ),
            child: Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                AshlarIcon(
                  AshlarIconsRoundedStroke.calendar,
                  size: 16,
                  color: c.mutedForeground,
                ),
                const SizedBox(width: 8),
                Flexible(
                  child: Text(
                    shown ?? widget.placeholder,
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: t.text.sm.copyWith(
                      color: shown == null ? c.mutedForeground : c.foreground,
                    ),
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}