Files
Garage-SDKs/garage_ui/lib/date_input.dart
T
ImBenjiandClaude Opus 5.5 b269201919 The Garage SDKs, in the open
garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of
Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme,
docs under docs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
2026-09-23 18:49:21 +01:00

1373 lines
41 KiB
Dart

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<DateTime?>? 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<DateInput> createState() => _DateInputState();
}
class _DateInputState extends State<DateInput> {
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<DateRange?>? 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<DateRangeInput> createState() => _DateRangeInputState();
}
class _DateRangeInputState extends State<DateRangeInput> {
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<ActivateIntent>(
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<DateTime> onViewChanged;
final ValueChanged<DateTime> 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 = <Widget>[];
for (var row = 0; row < _gridRows; row++) {
final cells = <Widget>[];
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<int> values;
final String Function(int value) label;
final int selected;
final double height;
final ValueChanged<int> 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 = <Widget>[];
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<DateTime> 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,
),
),
),
);
}
}