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
1373 lines
41 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|