import "package:flutter/widgets.dart"; import "package:flutter_lucide/flutter_lucide.dart"; import "package:garage_ui/button.dart"; import "package:garage_ui/color_input.dart" show PromptMode; import "package:garage_ui/extensions.dart"; import "package:garage_ui/popover_placement.dart"; import "package:garage_ui/semantics_scope.dart"; import "package:garage_ui/surface.dart"; import "package:garage_ui/theme/garage_theme.dart"; import "package:garage_ui/theme/support.dart"; // date pickers, built the same shape as ColorInput: a trigger you click, an // OverlayEntry popover in the ROOT overlay so a panel cant clip it, and the // value commited when the popover closes. // // two flavours - DateInput for one date, DateRangeInput for a start/end pair // with the two little date read outs across the top. they share the month grid // underneath, since a single date is really just a range whose ends match. /// A start/end pair. Both ends are inclusive. @immutable class DateRange { const DateRange(this.start, this.end); final DateTime start; final DateTime end; bool contains(DateTime day) { final d = _dayOnly(day); return !d.isBefore(_dayOnly(start)) && !d.isAfter(_dayOnly(end)); } Duration get length => _dayOnly(end).difference(_dayOnly(start)); @override bool operator ==(Object other) => other is DateRange && _sameDay(other.start, start) && _sameDay(other.end, end); @override int get hashCode => Object.hash(_dayOnly(start), _dayOnly(end)); @override String toString() => "DateRange(${_shortDate(start)} - ${_shortDate(end)})"; } /// How the trigger field is filled. Mirrors [TextFieldVariant] and /// SelectVariant so a date field lines up with whatever it sits beside. enum DateInputVariant { /// controlFill fill, controlBorder stroke. The default - a date field is /// on a form next to text fields far more often than its in a toolbar. outline, /// Solid secondary fill, same as Button.secondary. secondary, } // which end of a range the next tap lands on. enum _RangeEnd { start, end } DateTime _dayOnly(DateTime d) => DateTime(d.year, d.month, d.day); bool _sameDay(DateTime a, DateTime b) => a.year == b.year && a.month == b.month && a.day == b.day; String _two(int v) => v.toString().padLeft(2, "0"); // dd/mm/yy - short enough to sit in the range read outs without wrapping. String _shortDate(DateTime d) => "${_two(d.day)}/${_two(d.month)}/${_two(d.year % 100)}"; // d MMM yyyy ("1 Jul 2026"). what the trigger field shows - it has the room, // and the month name kills the is-it-american ambiguity of an all digit date. // no zero padding here, unlike the compact read outs, since nothing lines up // in a column for it to help. String _fieldDate(DateTime d) => "${d.day} ${_monthNames[d.month - 1].substring(0, 3)} ${d.year}"; const _monthNames = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; // sunday first, matching the grid below. const _weekdayNames = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]; // the calendar always draws 6 rows so the popover doesnt resize as you page // between a 4 row february and a 6 row month. const _gridRows = 6; const _gridColumns = 7; const _cardPadding = 8.0; // the year dropdown needs SOME bounds to build a list from. a century back // covers birthdays, a decade forward covers anything youd schedule. int _defaultMinYear() => DateTime.now().year - 100; int _defaultMaxYear() => DateTime.now().year + 10; /// Pick a single date. class DateInput extends StatefulWidget { const DateInput({ super.key, required this.value, this.onChanged, this.placeholder = "Pick a date", this.variant = DateInputVariant.outline, this.promptMode = PromptMode.popover, this.popoverAlignment = Alignment.bottomLeft, this.isDateEnabled, this.minYear, this.maxYear, this.enabled = true, }); final DateTime? value; final ValueChanged? onChanged; final String placeholder; /// Overridden to [DateInputVariant.secondary] inside a property row. final DateInputVariant variant; // accepted for symmetry with ColorInput. only the popover path is wired up, // exactly like the colour picker today. final PromptMode promptMode; final Alignment popoverAlignment; /// Grey out (and refuse) individual days. final bool Function(DateTime day)? isDateEnabled; /// Bounds the year dropdown, and stops the arrows paging past them. final int? minYear; final int? maxYear; final bool enabled; @override State createState() => _DateInputState(); } class _DateInputState extends State { final LayerLink _link = LayerLink(); OverlayEntry? _entry; late DateTime _view; DateTime? _selected; double _maxPopoverHeight = double.infinity; @override void initState() { super.initState(); _selected = widget.value; _view = _monthOf(widget.value ?? DateTime.now()); } @override void didUpdateWidget(covariant DateInput old) { super.didUpdateWidget(old); // same rule as ColorInput - only follow the outside world while were shut, // otherwise our own picking fights the incoming value. if (_entry == null) { _selected = widget.value; _view = _monthOf(widget.value ?? DateTime.now()); } } void _open() { if (_entry != null || !widget.enabled) return; final overlay = Overlay.of(context, rootOverlay: true); final theme = GarageTheme.of(context); final size = _calendarSize(theme, withRangeHeader: false); final placement = _placementFor(overlay, size); _maxPopoverHeight = placement.maxHeight; _entry = OverlayEntry( builder: (ctx) => _PopoverShell( link: _link, placement: placement, theme: theme, onDismiss: _close, child: _CalendarCard( theme: theme, view: _view, start: _selected, end: _selected, maxHeight: _maxPopoverHeight, isDateEnabled: widget.isDateEnabled, minYear: widget.minYear ?? _defaultMinYear(), maxYear: widget.maxYear ?? _defaultMaxYear(), onViewChanged: (v) => _update(() => _view = v), onDayTapped: _pick, ), ), ); overlay.insert(_entry!); } void _update(VoidCallback fn) { setState(fn); _entry?.markNeedsBuild(); } void _pick(DateTime day) { _selected = day; _view = _monthOf(day); setState(() {}); _close(); } void _close() { _entry?.remove(); _entry = null; widget.onChanged?.call(_selected); } PopoverPlacement _placementFor(OverlayState overlay, Size preferred) { try { return resolvePopoverPlacement( target: context.findRenderObject()! as RenderBox, overlay: overlay.context.findRenderObject()! as RenderBox, preferredSize: preferred, preferBelow: widget.popoverAlignment.y >= 0, preferRight: widget.popoverAlignment.x <= 0, gap: 6, ); } catch (error, stack) { // an unlaid-out trigger shouldnt happen from a tap, but dont lose the // popover over it - drop it below and carry on. debugPrint("DateInput: could not measure popover placement: $error"); debugPrintStack(stackTrace: stack); return const PopoverPlacement( targetAnchor: Alignment.bottomLeft, followerAnchor: Alignment.topLeft, offset: Offset(0, 6), maxWidth: double.infinity, maxHeight: double.infinity, openBelow: true, openToRight: true, ); } } @override void dispose() { _entry?.remove(); _entry = null; super.dispose(); } @override Widget build(BuildContext context) { return CompositedTransformTarget( link: _link, child: _TriggerField( label: _selected == null ? widget.placeholder : _fieldDate(_selected!), muted: _selected == null, enabled: widget.enabled, variant: widget.variant, semanticLabel: "Date", onTap: _open, ), ); } } /// Pick a start and an end date. class DateRangeInput extends StatefulWidget { const DateRangeInput({ super.key, required this.value, this.onChanged, this.placeholder = "Pick dates", this.variant = DateInputVariant.outline, this.promptMode = PromptMode.popover, this.popoverAlignment = Alignment.bottomLeft, this.isDateEnabled, this.minYear, this.maxYear, this.enabled = true, }); final DateRange? value; final ValueChanged? onChanged; final String placeholder; /// Overridden to [DateInputVariant.secondary] inside a property row. final DateInputVariant variant; final PromptMode promptMode; final Alignment popoverAlignment; final bool Function(DateTime day)? isDateEnabled; /// Bounds the year dropdown, and stops the arrows paging past them. final int? minYear; final int? maxYear; final bool enabled; @override State createState() => _DateRangeInputState(); } class _DateRangeInputState extends State { final LayerLink _link = LayerLink(); OverlayEntry? _entry; late DateTime _view; DateTime? _start; DateTime? _end; var _arming = _RangeEnd.start; double _maxPopoverHeight = double.infinity; @override void initState() { super.initState(); _adoptValue(); _view = _monthOf(widget.value?.start ?? DateTime.now()); } @override void didUpdateWidget(covariant DateRangeInput old) { super.didUpdateWidget(old); if (_entry == null) { _adoptValue(); _view = _monthOf(widget.value?.start ?? DateTime.now()); } } void _adoptValue() { _start = widget.value?.start; _end = widget.value?.end; _arming = _RangeEnd.start; } void _open() { if (_entry != null || !widget.enabled) return; final overlay = Overlay.of(context, rootOverlay: true); final theme = GarageTheme.of(context); final size = _calendarSize(theme, withRangeHeader: true); final placement = _placementFor(overlay, size); _maxPopoverHeight = placement.maxHeight; _entry = OverlayEntry( builder: (ctx) => _PopoverShell( link: _link, placement: placement, theme: theme, onDismiss: _close, child: _CalendarCard( theme: theme, view: _view, start: _start, end: _end, maxHeight: _maxPopoverHeight, isDateEnabled: widget.isDateEnabled, minYear: widget.minYear ?? _defaultMinYear(), maxYear: widget.maxYear ?? _defaultMaxYear(), onViewChanged: (v) => _update(() => _view = v), onDayTapped: _pick, header: _RangeHeader( theme: theme, start: _start, end: _end, arming: _arming, onArm: (which) => _update(() => _arming = which), ), ), ), ); overlay.insert(_entry!); } void _update(VoidCallback fn) { setState(fn); _entry?.markNeedsBuild(); } void _pick(DateTime day) { _update(() { switch (_arming) { case _RangeEnd.start: _start = day; // a start after the existing end would make an inside out range, // so drop the end and let them pick a fresh one. if (_end != null && _end!.isBefore(day)) _end = null; _arming = _RangeEnd.end; case _RangeEnd.end: if (_start == null || day.isBefore(_start!)) { // clicked behind the start - treat it as moving the start rather // than refusing the click, which is what every other picker does. _start = day; } else { _end = day; _arming = _RangeEnd.start; } } }); } void _close() { _entry?.remove(); _entry = null; final s = _start; final e = _end; widget.onChanged?.call(s != null && e != null ? DateRange(s, e) : null); } PopoverPlacement _placementFor(OverlayState overlay, Size preferred) { try { return resolvePopoverPlacement( target: context.findRenderObject()! as RenderBox, overlay: overlay.context.findRenderObject()! as RenderBox, preferredSize: preferred, preferBelow: widget.popoverAlignment.y >= 0, preferRight: widget.popoverAlignment.x <= 0, gap: 6, ); } catch (error, stack) { debugPrint("DateRangeInput: could not measure popover placement: $error"); debugPrintStack(stackTrace: stack); return const PopoverPlacement( targetAnchor: Alignment.bottomLeft, followerAnchor: Alignment.topLeft, offset: Offset(0, 6), maxWidth: double.infinity, maxHeight: double.infinity, openBelow: true, openToRight: true, ); } } @override void dispose() { _entry?.remove(); _entry = null; super.dispose(); } @override Widget build(BuildContext context) { final s = _start; final e = _end; final label = s == null && e == null ? widget.placeholder : "${s == null ? "-" : _fieldDate(s)} - ${e == null ? "-" : _fieldDate(e)}"; return CompositedTransformTarget( link: _link, child: _TriggerField( label: label, muted: s == null && e == null, enabled: widget.enabled, variant: widget.variant, semanticLabel: "Date range", onTap: _open, ), ); } } DateTime _monthOf(DateTime d) => DateTime(d.year, d.month); // worked out up front so the placement maths knows how much room to ask for. // the cards border is painted INSIDE its box, so the stroke has to be added // back on top of the grid + padding or saturday gets clipped off the end. double _cardWidth(ThemeData theme) => _cellSize(theme) * _gridColumns + _cardPadding * 2 + theme.scaling * 2; // only feeds the flip-side decision, so its an estimate rather than a measure. Size _calendarSize(ThemeData theme, {required bool withRangeHeader}) { final density = theme.density; final cell = _cellSize(theme); var height = density.controlHeight + // the month/year selects density.gapXs * 2 + // their inset 1 + // divider under the chrome _cardPadding * 2 + cell * 0.8 + // weekday strip cell * _gridRows; if (withRangeHeader) height += density.controlHeight + 12 + 1; return Size(_cardWidth(theme), height); } double _cellSize(ThemeData theme) => 34.0 * theme.scaling; // The trigger. Deliberately field shaped rather than swatch shaped - a date // needs to show its text, unlike a colour. class _TriggerField extends StatefulWidget { const _TriggerField({ required this.label, required this.muted, required this.enabled, required this.variant, required this.semanticLabel, required this.onTap, }); final String label; final bool muted; final bool enabled; final DateInputVariant variant; final String semanticLabel; final VoidCallback onTap; @override State<_TriggerField> createState() => _TriggerFieldState(); } class _TriggerFieldState extends State<_TriggerField> { var _hovered = false; var _focused = false; @override Widget build(BuildContext context) { final theme = GarageTheme.of(context); final cs = theme.colorScheme; final enabled = widget.enabled; // inside a property row the variant isnt the call sites to pick - see // PropertySlotScope. explicit loses here, same as TextField and Select. final variant = PropertySlotScope.of(context) ? DateInputVariant.secondary : widget.variant; final variantForeground = variant == DateInputVariant.secondary ? cs.secondaryForeground : cs.foreground; final style = theme.typography.small.copyWith( color: (!enabled || widget.muted) ? cs.mutedForeground : variantForeground, ); final field = Container( height: theme.density.controlHeight, padding: EdgeInsets.symmetric(horizontal: theme.density.buttonPaddingX), decoration: _decoration( theme, variant, hovered: _hovered, enabled: enabled, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ // leading, not trailing - the glyph says what the field IS, and the // kit keeps identity on the left (properties.dart's button icons, // color_input's channel labels). trailing is for disclosure, which // is why Select's chevron lives there and this doesnt. IconTheme.merge( data: IconThemeData(color: cs.mutedForeground), child: const Icon(LucideIcons.calendar_days).iconSmall, ), SizedBox(width: theme.density.controlGap), Flexible( child: Text( widget.label, style: style, overflow: TextOverflow.ellipsis, maxLines: 1, ), ), ], ), ); return Semantics( container: true, button: true, enabled: enabled, label: widget.semanticLabel, value: widget.label, onTap: enabled ? widget.onTap : null, child: FocusableActionDetector( enabled: enabled, mouseCursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.basic, onShowHoverHighlight: (v) => setState(() => _hovered = v), onShowFocusHighlight: (v) => setState(() => _focused = v), actions: { ActivateIntent: CallbackAction( onInvoke: (_) { widget.onTap(); return null; }, ), }, child: GestureDetector( onTap: enabled ? widget.onTap : null, excludeFromSemantics: true, child: FocusOutline( focused: _focused && enabled, borderRadius: theme.borderRadiusMd, child: field, ), ), ), ); } // same fills the Select variants draw, so a DateInput sitting next to a // Select or a TextField in a row comes out identical. BoxDecoration _decoration( ThemeData theme, DateInputVariant variant, { required bool hovered, required bool enabled, }) { final cs = theme.colorScheme; final width = theme.density.controlBorderWidth; switch (variant) { case DateInputVariant.outline: return BoxDecoration( color: (hovered && enabled) ? cs.controlFillHovered : cs.controlFill, border: Border.all(color: cs.controlBorder, width: width), borderRadius: theme.borderRadiusMd, ); case DateInputVariant.secondary: return BoxDecoration( color: (hovered && enabled) ? cs.secondaryHovered : cs.secondary, border: Border.all(color: cs.controlBorder, width: width), borderRadius: theme.borderRadiusMd, ); } } } // the dismiss barrier + follower, lifted straight out of ColorInput._open. class _PopoverShell extends StatelessWidget { const _PopoverShell({ required this.link, required this.placement, required this.theme, required this.onDismiss, required this.child, }); final LayerLink link; final PopoverPlacement placement; final ThemeData theme; final VoidCallback onDismiss; final Widget child; @override Widget build(BuildContext context) { return Stack( children: [ Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, excludeFromSemantics: true, onTap: onDismiss, ), ), CompositedTransformFollower( link: link, showWhenUnlinked: false, targetAnchor: placement.targetAnchor, followerAnchor: placement.followerAnchor, offset: placement.offset, // the root Overlay can sit above GarageTheme, so carry the callers // theme across or the popup wont build. child: GarageTheme(data: theme, child: child), ), ], ); } } // what the body below the header is showing. enum _CardView { days, months, years } class _CalendarCard extends StatefulWidget { const _CalendarCard({ required this.theme, required this.view, required this.start, required this.end, required this.maxHeight, required this.onViewChanged, required this.onDayTapped, required this.minYear, required this.maxYear, this.isDateEnabled, this.header, }); final ThemeData theme; final DateTime view; final DateTime? start; final DateTime? end; final double maxHeight; final ValueChanged onViewChanged; final ValueChanged onDayTapped; final bool Function(DateTime day)? isDateEnabled; final int minYear; final int maxYear; final Widget? header; @override State<_CalendarCard> createState() => _CalendarCardState(); } class _CalendarCardState extends State<_CalendarCard> { var _mode = _CardView.days; ThemeData get theme => widget.theme; DateTime get view => widget.view; // a view year outside the callers bounds still has to appear in the list, // otherwise picking a year is the only way back and it isnt there. int get _firstYear => view.year < widget.minYear ? view.year : widget.minYear; int get _lastYear => view.year > widget.maxYear ? view.year : widget.maxYear; void _show(_CardView mode) { setState(() => _mode = _mode == mode ? _CardView.days : mode); } @override Widget build(BuildContext context) { final cs = theme.colorScheme; final cell = _cellSize(theme); // the body keeps the day grids height in every mode, so the popover never // jumps size (or re-flips) just because you opened the month list. final bodyHeight = cell * 0.8 + cell * _gridRows; return Container( width: _cardWidth(theme), decoration: BoxDecoration( color: cs.popover, borderRadius: theme.borderRadiusLg, border: Border.all(color: cs.popoverBorder, width: theme.scaling), boxShadow: const [ BoxShadow( color: Color(0x33000000), blurRadius: 12, offset: Offset(0, 4), ), ], ), // the chrome band runs edge to edge, so it has to be clipped back to // the cards radius or its square corners poke out past the stroke. child: ClipRRect( borderRadius: subtractByBorder(theme.borderRadiusLg, theme.scaling), child: ConstrainedBox( constraints: BoxConstraints(maxHeight: widget.maxHeight), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // `card` and NOT `muted`, even though muted is the scheme's // usual band fill: ghost buttons hover to muted-at-80%, so a // muted band swallows the hover on the month/year buttons // entirely. card is the other sanctioned section tone (its // what card resolves to) and it sits a // good 14 points off popover, so the band actually reads. ColoredBox( color: cs.card, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (widget.header != null) ...[ widget.header!, const Divider(), ], _monthBar(cs), ], ), ), const Divider(), Padding( padding: const EdgeInsets.all(_cardPadding), child: SizedBox( height: bodyHeight, child: _body(cs, cell, bodyHeight), ), ), ], ), ), ), ), ); } Widget _body(ColourScheme cs, double cell, double bodyHeight) { switch (_mode) { case _CardView.days: return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [_weekdayBar(cs, cell), _grid(cell)], ); case _CardView.months: return _PickerList( // keyed by mode, or flutter reuses the month lists State (and so its // scroll controller) for the year list, and the years open pinned to // the top instead of on the selected one. key: const ValueKey(_CardView.months), columns: 2, theme: theme, values: [for (var m = 1; m <= 12; m++) m], label: (m) => _monthNames[m - 1], selected: view.month, height: bodyHeight, onPick: (m) { widget.onViewChanged(DateTime(view.year, m)); setState(() => _mode = _CardView.days); }, ); case _CardView.years: return _PickerList( key: const ValueKey(_CardView.years), columns: 4, theme: theme, values: [for (var y = _firstYear; y <= _lastYear; y++) y], label: (y) => "$y", selected: view.year, height: bodyHeight, onPick: (y) { widget.onViewChanged(DateTime(y, view.month)); setState(() => _mode = _CardView.days); }, ); } } Widget _monthBar(ColourScheme cs) { final atStart = view.year <= _firstYear && view.month == 1; final atEnd = view.year >= _lastYear && view.month == 12; final browsing = _mode != _CardView.days; return Padding( padding: EdgeInsets.all(theme.density.gapXs), child: Row( children: [ // the arrows page the day grid, so they only make sense while its // the thing on screen - but they keep their footprint while a list // is up, otherwise the month/year buttons grow into the gap and the // whole header jumps every time you open one. Visibility( visible: !browsing, maintainSize: true, maintainAnimation: true, maintainState: true, child: IconButton.ghost( icon: const Icon(LucideIcons.chevron_left).iconSmall, size: ButtonSize.small, semanticLabel: "Previous month", onPressed: atStart ? null : () => widget.onViewChanged( DateTime(view.year, view.month - 1), ), ), ), Expanded( flex: 3, child: _chromeButton( label: _monthNames[view.month - 1], open: _mode == _CardView.months, semanticLabel: "Month", onPressed: () => _show(_CardView.months), ), ), SizedBox(width: theme.density.gapXs), Expanded( flex: 2, child: _chromeButton( label: "${view.year}", open: _mode == _CardView.years, semanticLabel: "Year", onPressed: () => _show(_CardView.years), ), ), Visibility( visible: !browsing, maintainSize: true, maintainAnimation: true, maintainState: true, child: IconButton.ghost( icon: const Icon(LucideIcons.chevron_right).iconSmall, size: ButtonSize.small, semanticLabel: "Next month", onPressed: atEnd ? null : () => widget.onViewChanged( DateTime(view.year, view.month + 1), ), ), ), ], ), ); } Widget _chromeButton({ required String label, required bool open, required String semanticLabel, required VoidCallback onPressed, }) { // plain ghost. no trailing chevron - that was the dropdown affordance // carried over from the Select, and these arent dropdowns. `open` only // survives for the semantics, so a screen reader still knows which list // is up even though nothing on screen shouts about it. return Button.ghost( onPressed: onPressed, semanticLabel: semanticLabel, semanticSelected: open, child: Text(label, overflow: TextOverflow.ellipsis, maxLines: 1), ); } Widget _weekdayBar(ColourScheme cs, double cell) { final style = theme.typography.small.copyWith( color: cs.mutedForeground, fontSize: theme.density.fontSize * 0.9, ); return Row( children: [ for (final name in _weekdayNames) SizedBox( width: cell, height: cell * 0.8, child: Center(child: Text(name, style: style)), ), ], ); } Widget _grid(double cell) { // dart counts monday..sunday as 1..7, the grid is sunday first, so %7 // lands sunday back on column zero. final leading = DateTime(view.year, view.month).weekday % _gridColumns; final s = widget.start == null ? null : _dayOnly(widget.start!); final e = widget.end == null ? null : _dayOnly(widget.end!); final hasSpan = s != null && e != null && e.isAfter(s); final rows = []; for (var row = 0; row < _gridRows; row++) { final cells = []; for (var col = 0; col < _gridColumns; col++) { // DateTime normalises day overflow/underflow for us, so this walks // cleanly out into the neighbouring months. final day = DateTime( view.year, view.month, 1 - leading + (row * _gridColumns + col), ); final isStart = s != null && _sameDay(day, s); final isEnd = e != null && _sameDay(day, e); final inSpan = hasSpan && day.isAfter(s) && day.isBefore(e); cells.add( _DayCell( theme: theme, day: day, size: cell, outsideMonth: day.month != view.month, isEndpoint: isStart || isEnd, inSpan: inSpan, bandLeft: hasSpan && (inSpan || isEnd) && col != 0, bandRight: hasSpan && (inSpan || isStart) && col != _gridColumns - 1, enabled: widget.isDateEnabled?.call(day) ?? true, onTap: widget.onDayTapped, ), ); } rows.add(Row(children: cells)); } return Column(mainAxisSize: MainAxisSize.min, children: rows); } } // the month/year list that replaces the day grid. opens scrolled to whatever // is currently selected, which matters a lot for a hundred odd years. class _PickerList extends StatefulWidget { const _PickerList({ super.key, required this.theme, required this.values, required this.label, required this.selected, required this.height, required this.onPick, required this.columns, }); final ThemeData theme; final List values; final String Function(int value) label; final int selected; final double height; final ValueChanged onPick; /// How many across. The card is seven day-cells wide, so one per row wasted /// most of it - month names pair up, bare years go four across. final int columns; @override State<_PickerList> createState() => _PickerListState(); } class _PickerListState extends State<_PickerList> { late final ScrollController _controller; int get _rowCount => (widget.values.length + widget.columns - 1) ~/ widget.columns; @override void initState() { super.initState(); final rowHeight = widget.theme.density.popupRowHeight; final index = widget.values.indexOf(widget.selected); final maxOffset = _rowCount * rowHeight - widget.height; // centre the selection rather than just bringing it on screen - landing // with the current year pinned to the top edge reads as "top of the list". final row = index < 0 ? 0 : index ~/ widget.columns; final wanted = index < 0 ? 0.0 : row * rowHeight - (widget.height - rowHeight) / 2; _controller = ScrollController( initialScrollOffset: wanted.clamp(0.0, maxOffset < 0 ? 0.0 : maxOffset), ); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = widget.theme; final rowHeight = theme.density.popupRowHeight; final gap = theme.density.gapXs; return ListView.builder( controller: _controller, itemExtent: rowHeight, itemCount: _rowCount, padding: EdgeInsets.zero, itemBuilder: (context, row) { final cells = []; for (var col = 0; col < widget.columns; col++) { if (col > 0) cells.add(SizedBox(width: gap)); final i = row * widget.columns + col; // a ragged last row still has to hold its columns open, otherwise // the final entry stretches the full width and looks like a heading. if (i >= widget.values.length) { cells.add(const Expanded(child: SizedBox())); continue; } final value = widget.values[i]; cells.add( Expanded( child: _PickerRow( theme: theme, label: widget.label(value), selected: value == widget.selected, onTap: () => widget.onPick(value), ), ), ); } return Row(children: cells); }, ); } } class _PickerRow extends StatefulWidget { const _PickerRow({ required this.theme, required this.label, required this.selected, required this.onTap, }); final ThemeData theme; final String label; final bool selected; final VoidCallback onTap; @override State<_PickerRow> createState() => _PickerRowState(); } class _PickerRowState extends State<_PickerRow> { var _hovered = false; @override Widget build(BuildContext context) { final theme = widget.theme; final cs = theme.colorScheme; final Color? fill; if (widget.selected) { fill = cs.primary; } else if (_hovered) { fill = cs.popoverItemHovered; } else { fill = null; } return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: Semantics( button: true, selected: widget.selected, label: widget.label, onTap: widget.onTap, child: GestureDetector( excludeFromSemantics: true, onTap: widget.onTap, child: Container( // centred, not start aligned - the cells are a grid now, and // ragged left aligned labels in columns of unequal word length // read as badly kerned rather than as a list. alignment: Alignment.center, padding: EdgeInsets.symmetric(horizontal: theme.density.gapXs), decoration: BoxDecoration( color: fill, borderRadius: theme.borderRadiusSm, ), child: Text( widget.label, maxLines: 1, textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, style: theme.typography.small.copyWith( color: widget.selected ? cs.primaryForeground : cs.foreground, ), ), ), ), ), ); } } class _DayCell extends StatefulWidget { const _DayCell({ required this.theme, required this.day, required this.size, required this.outsideMonth, required this.isEndpoint, required this.inSpan, required this.bandLeft, required this.bandRight, required this.enabled, required this.onTap, }); final ThemeData theme; final DateTime day; final double size; final bool outsideMonth; final bool isEndpoint; final bool inSpan; // whether the light range band runs off this cell towards that side. false // means the band stops here and gets its rounded cap. final bool bandLeft; final bool bandRight; final bool enabled; final ValueChanged onTap; @override State<_DayCell> createState() => _DayCellState(); } class _DayCellState extends State<_DayCell> { var _hovered = false; @override Widget build(BuildContext context) { final theme = widget.theme; final cs = theme.colorScheme; final radius = Radius.circular(theme.radiusMd); final banded = widget.inSpan || (widget.isEndpoint && (widget.bandLeft || widget.bandRight)); final Color fg; if (!widget.enabled) { fg = cs.mutedForeground.scaleAlpha(0.4); } else if (widget.isEndpoint) { fg = cs.primaryForeground; } else if (widget.outsideMonth) { fg = cs.mutedForeground.scaleAlpha(0.7); } else { fg = cs.foreground; } Widget label = Center( child: Text( "${widget.day.day}", style: theme.typography.small.copyWith(color: fg), ), ); if (widget.isEndpoint) { label = DecoratedBox( decoration: BoxDecoration( color: cs.primary, borderRadius: theme.borderRadiusMd, ), child: label, ); } else if (_hovered && widget.enabled) { label = DecoratedBox( decoration: BoxDecoration( color: cs.popoverItemHovered, borderRadius: theme.borderRadiusMd, ), child: label, ); } return MouseRegion( cursor: widget.enabled ? SystemMouseCursors.click : SystemMouseCursors.basic, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: Semantics( button: true, enabled: widget.enabled, selected: widget.isEndpoint, label: _fieldDate(widget.day), onTap: widget.enabled ? () => widget.onTap(widget.day) : null, child: GestureDetector( excludeFromSemantics: true, onTap: widget.enabled ? () => widget.onTap(widget.day) : null, child: SizedBox( width: widget.size, height: widget.size, child: DecoratedBox( decoration: BoxDecoration( color: banded ? cs.primary.scaleAlpha(0.10) : null, borderRadius: BorderRadius.horizontal( left: widget.bandLeft ? Radius.zero : radius, right: widget.bandRight ? Radius.zero : radius, ), ), child: Padding( padding: EdgeInsets.symmetric(vertical: 1 * theme.scaling), child: label, ), ), ), ), ), ); } } // the two date read outs across the top of a range picker. tapping one arms // that end, so you can nudge a single side without redoing the whole range. class _RangeHeader extends StatelessWidget { const _RangeHeader({ required this.theme, required this.start, required this.end, required this.arming, required this.onArm, }); final ThemeData theme; final DateTime? start; final DateTime? end; final _RangeEnd arming; final ValueChanged<_RangeEnd> onArm; @override Widget build(BuildContext context) { final cs = theme.colorScheme; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), child: Row( children: [ Expanded( child: _slot( cs, value: start, active: arming == _RangeEnd.start, align: TextAlign.start, label: "Start date", onTap: () => onArm(_RangeEnd.start), ), ), Text( "-", style: theme.typography.small.copyWith(color: cs.mutedForeground), ), Expanded( child: _slot( cs, value: end, active: arming == _RangeEnd.end, align: TextAlign.end, label: "End date", onTap: () => onArm(_RangeEnd.end), ), ), ], ), ); } Widget _slot( ColourScheme cs, { required DateTime? value, required bool active, required TextAlign align, required String label, required VoidCallback onTap, }) { final style = theme.typography.small.copyWith( color: value == null ? cs.mutedForeground : cs.foreground, ); return Semantics( button: true, selected: active, label: label, value: value == null ? "not set" : _shortDate(value), onTap: onTap, child: GestureDetector( onTap: onTap, excludeFromSemantics: true, behavior: HitTestBehavior.opaque, child: Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), decoration: BoxDecoration( color: active ? cs.primary.scaleAlpha(0.10) : null, borderRadius: theme.borderRadiusSm, ), child: Text( value == null ? "--/--/--" : _shortDate(value), textAlign: align, style: style, ), ), ), ); } }