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
1372 lines
50 KiB
Dart
1372 lines
50 KiB
Dart
// hand rolled replacement for shadcn's Select (dropdown + popover).
|
|
// pixel-identical port of shadcn_flutter 0.0.52 form/select.dart — the closed
|
|
// outline trigger, the popover surface (card bg, muted border, xl radius) and
|
|
// the ghost item buttons with their check trailing.
|
|
//
|
|
// we only build the slice the app actually uses: single-select, static item
|
|
// lists + the searchable builder popup. multi-select / controlled / theming
|
|
// variants aren't wired up here (nothing in the app touches them).
|
|
//
|
|
// like the other GarageUI files we still read shadcn's Theme/ColorScheme + the
|
|
// scaleAlpha extension + the bundled LucideIcons during the migration. that
|
|
// import gets repointed later. everything else is plain flutter.
|
|
|
|
import "dart:async";
|
|
import "dart:math" as math;
|
|
import "dart:ui" as ui;
|
|
|
|
import "package:flutter/services.dart";
|
|
import "package:flutter/widgets.dart";
|
|
|
|
import "package:garage_ui/field_error.dart";
|
|
import "package:garage_ui/popover_placement.dart";
|
|
import "package:garage_ui/surface.dart" show Gap;
|
|
import "package:garage_ui/semantics_scope.dart";
|
|
import "package:garage_ui/theme/garage_theme.dart";
|
|
import "package:garage_ui/theme/support.dart";
|
|
import "package:garage_ui/button.dart";
|
|
import "package:flutter_lucide/flutter_lucide.dart";
|
|
|
|
/// builds the widget for a selected value (shown in the closed trigger).
|
|
typedef SelectValueBuilder<T> = Widget Function(BuildContext context, T value);
|
|
|
|
/// builds the popup content. a [SelectPopup] instance is itself callable as
|
|
/// one of these (it just returns itself), which is how the call sites pass
|
|
/// `popup: SelectPopup(...)` directly.
|
|
typedef SelectPopupBuilder = Widget Function(BuildContext context);
|
|
|
|
/// builds the item delegate for a given search query (nullable when empty).
|
|
typedef SelectItemsBuilder<T> =
|
|
FutureOr<SelectItemDelegate?> Function(
|
|
BuildContext context,
|
|
String? searchQuery,
|
|
);
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// internal wiring between the trigger and the popover
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
// carries the selection state + the select/close callbacks down into the
|
|
// popover subtree (which lives in the Overlay, outside the trigger's tree).
|
|
// this is our stand-in for shadcn's SelectData + SelectPopupHandle.
|
|
class _SelectHandle {
|
|
final bool Function(Object? value) isSelected;
|
|
final void Function(Object? value, bool selected) selectItem;
|
|
final bool hasSelection;
|
|
final bool enabled;
|
|
final ControlDensity? density;
|
|
|
|
const _SelectHandle({
|
|
required this.isSelected,
|
|
required this.selectItem,
|
|
required this.hasSelection,
|
|
required this.enabled,
|
|
required this.density,
|
|
});
|
|
}
|
|
|
|
class _SelectScope extends InheritedWidget {
|
|
final _SelectHandle handle;
|
|
|
|
const _SelectScope({required this.handle, required super.child});
|
|
|
|
static _SelectHandle? maybeOf(BuildContext context) {
|
|
return context.dependOnInheritedWidgetOfExactType<_SelectScope>()?.handle;
|
|
}
|
|
|
|
@override
|
|
bool updateShouldNotify(_SelectScope oldWidget) {
|
|
// handle is rebuilt every open, so just always notify. cheap enough.
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// the select widget
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/// a dropdown that opens a popover of options. drive it with [value] +
|
|
/// [onChanged] — it doesn't hold its own selection, the parent does.
|
|
/// Which control family a [Select] trigger draws as. Mirrors the button and
|
|
/// text field variants of the same names.
|
|
enum SelectVariant {
|
|
/// Input-style: controlFill fill, controlBorder stroke. The default,
|
|
/// matching TextField and Button.outline.
|
|
outline,
|
|
|
|
/// Solid secondary fill, same as Button.secondary.
|
|
secondary,
|
|
|
|
/// No fill and no stroke until you point at it, same as Button.ghost.
|
|
///
|
|
/// For a select thats reporting a VALUE rather than offering a control - a
|
|
/// settings row where the whole right hand column is values, and a box round
|
|
/// every one of them is a box too many.
|
|
ghost,
|
|
}
|
|
|
|
class Select<T> extends StatefulWidget {
|
|
/// called when a new value is picked. if null the select is disabled.
|
|
final ValueChanged<T?>? onChanged;
|
|
|
|
/// shown (muted) in the trigger when nothing is selected.
|
|
final Widget? placeholder;
|
|
|
|
/// the current value. null means "show the placeholder".
|
|
final T? value;
|
|
|
|
/// builds the trigger content for the current value.
|
|
final SelectValueBuilder<T> itemBuilder;
|
|
|
|
/// Builds a widget for the START of the trigger, from the same value
|
|
/// [itemBuilder] gets.
|
|
///
|
|
/// For the mark that identifies a value rather than names it - a line's
|
|
/// colour, a scheme's swatch. Inline in [itemBuilder] it rides along with
|
|
/// the label, and since the trigger centres its value that puts a centred
|
|
/// "swatch + label" block on screen with the swatch wandering left as the
|
|
/// label gets longer. Pinned to the edge it sits still, and the label
|
|
/// centres on its own.
|
|
final SelectValueBuilder<T>? leadingBuilder;
|
|
|
|
/// builds the popover. usually a [SelectPopup].
|
|
final SelectPopupBuilder popup;
|
|
|
|
/// hard override for enabled state. defaults to `onChanged != null`.
|
|
final bool? enabled;
|
|
|
|
/// Compact matches the app's standard controls; normal keeps the same
|
|
/// vertical rhythm but adds the roomier horizontal inset.
|
|
final ControlDensity? density;
|
|
|
|
/// Which control family the trigger draws as. Only the trigger changes -
|
|
/// the popup surface is the same either way.
|
|
final SelectVariant variant;
|
|
|
|
/// screen reader label naming what the select controls ("Line style"). The
|
|
/// trigger's own content is the current *value*, which is not the same
|
|
/// thing — without this you hear "Dashed, button" and no clue what of.
|
|
final String? semanticLabel;
|
|
|
|
/// How wide the open list is.
|
|
///
|
|
/// Null follows the trigger exactly - the list is the width of the thing it
|
|
/// came out of, lines up under it, and never sprawls across the window
|
|
/// because thats where the room happened to be.
|
|
///
|
|
/// Name it to override that. A square [iconOnly] trigger has no useful
|
|
/// width to follow, so it has to: a 44px list of country names is a column
|
|
/// of shredded text.
|
|
///
|
|
/// Either way its clamped to the room the overlay actually has, so it cant
|
|
/// open off the side of a phone.
|
|
final double? popupWidth;
|
|
|
|
/// Measure THIS for the list's width instead of the trigger.
|
|
///
|
|
/// For a select welded into a bigger control - a flag beside a phone number
|
|
/// - where the list should line up with the whole thing rather than with the
|
|
/// square that opens it. Put the key on the outer control.
|
|
///
|
|
/// Read when the list opens, not during build, so it cant be a LayoutBuilder
|
|
/// round the control: that runs its builder DURING layout, and tearing the
|
|
/// subtree down while navigating trips
|
|
/// `_debugRelayoutBoundaryAlreadyMarkedNeedsLayout`. By open time the layout
|
|
/// has settled and the render box just answers.
|
|
final GlobalKey? popupAnchorKey;
|
|
|
|
/// Drop the chevron and square the padding, for a trigger whose value IS
|
|
/// the whole control - a flag, a colour swatch, an icon.
|
|
///
|
|
/// Same relationship [IconButton] has to [Button]: the chevron and the wide
|
|
/// horizontal padding both exist to sit beside a LABEL, and with no label
|
|
/// they leave a control thats wider than it is tall for no reason.
|
|
final bool iconOnly;
|
|
|
|
const Select({
|
|
super.key,
|
|
this.iconOnly = false,
|
|
this.popupWidth,
|
|
this.popupAnchorKey,
|
|
this.onChanged,
|
|
this.placeholder,
|
|
this.value,
|
|
this.enabled,
|
|
this.density,
|
|
this.variant = SelectVariant.outline,
|
|
this.semanticLabel,
|
|
required this.itemBuilder,
|
|
this.leadingBuilder,
|
|
required this.popup,
|
|
});
|
|
|
|
@override
|
|
State<Select<T>> createState() => _SelectState<T>();
|
|
}
|
|
|
|
class _SelectState<T> extends State<Select<T>> {
|
|
final LayerLink _link = LayerLink();
|
|
final GlobalKey _triggerKey = GlobalKey();
|
|
OverlayEntry? _entry;
|
|
|
|
bool get _isOpen => _entry != null;
|
|
|
|
bool _isSelected(Object? value) => widget.value == value;
|
|
|
|
void _selectItem(Object? value, bool selected) {
|
|
// default single-select behaviour: picking selects, and since we never
|
|
// allow unselect (canUnselect is off everywhere in the app) a re-tap on
|
|
// the current value just closes without a change.
|
|
if (selected) {
|
|
widget.onChanged?.call(value as T?);
|
|
}
|
|
_close();
|
|
}
|
|
|
|
void _toggle() {
|
|
if (_isOpen) {
|
|
_close();
|
|
} else {
|
|
_open();
|
|
}
|
|
}
|
|
|
|
void _open() {
|
|
if (_isOpen) return;
|
|
|
|
final theme = GarageTheme.of(context);
|
|
final densityGap = theme.density.containerGap;
|
|
// How far the popup hangs off the trigger.
|
|
//
|
|
// containerGap is the panel-to-panel number and it reads as a gulf under
|
|
// a control you just clicked - the popup stops looking like it came OUT
|
|
// of the select. gapSm is a step down the same ladder, still off the
|
|
// control rather than glued to it.
|
|
final popupGap = theme.density.gapSm;
|
|
final maxHeight = theme.density.popupMaxHeight;
|
|
|
|
final render = _triggerKey.currentContext?.findRenderObject() as RenderBox?;
|
|
final anchorWidth = render?.size.width ?? 0.0;
|
|
|
|
final overlayWidth =
|
|
(Overlay.of(context).context.findRenderObject() as RenderBox?)
|
|
?.size
|
|
.width;
|
|
// a minimum against the trigger, then clamped to the room actually going.
|
|
// Resolved BEFORE placement so the side it opens to is chosen against the
|
|
// width it will really be, not the trigger's.
|
|
final anchored =
|
|
(widget.popupAnchorKey?.currentContext?.findRenderObject()
|
|
as RenderBox?)
|
|
?.size
|
|
.width;
|
|
// THE width, not a floor. A select is as wide as its trigger unless it
|
|
// was told otherwise.
|
|
//
|
|
// It used to be a minimum, free to grow to its widest row, on the
|
|
// reasoning that the trigger shows one value and the list has to show
|
|
// them all. What that actually produced was a list the width of whatever
|
|
// room the chosen side had - and a trigger in a panel against the right
|
|
// edge of the window has the entire window to its left, so picking a line
|
|
// opened a popup 1900px wide with one row in it.
|
|
var popupWidth = math.max(
|
|
anchorWidth,
|
|
math.max(anchored ?? 0.0, widget.popupWidth ?? 0.0),
|
|
);
|
|
if (overlayWidth != null) {
|
|
popupWidth = math.min(popupWidth, overlayWidth - densityGap * 2);
|
|
}
|
|
|
|
// flip above the trigger when there isnt room below, and clamp the list to
|
|
// whatever room the chosen side actually has - a fixed maxHeight would
|
|
// still overflow a short viewport even after flipping
|
|
final overlayBox =
|
|
Overlay.of(context).context.findRenderObject() as RenderBox?;
|
|
// Which side to hang off. Now that the popup is exactly as wide as its
|
|
// trigger the two sides put it in the same place, so this only really
|
|
// decides which edge it clamps against in the corner case where the room
|
|
// is narrower than the trigger - and the roomier side is the right answer
|
|
// to that either way.
|
|
final targetDx = (render != null && overlayBox != null)
|
|
? render.localToGlobal(Offset.zero, ancestor: overlayBox).dx
|
|
: 0.0;
|
|
final roomRight = overlayBox == null
|
|
? 0.0
|
|
: overlayBox.size.width - targetDx;
|
|
final roomLeft = render == null ? 0.0 : targetDx + render.size.width;
|
|
|
|
final placement = (render != null && overlayBox != null)
|
|
? resolvePopoverPlacement(
|
|
target: render,
|
|
overlay: overlayBox,
|
|
preferredSize: Size(popupWidth, maxHeight),
|
|
preferRight: roomRight >= roomLeft,
|
|
gap: popupGap,
|
|
)
|
|
: null;
|
|
final resolvedMaxHeight = placement == null
|
|
? maxHeight
|
|
: math.min(maxHeight, placement.maxHeight);
|
|
|
|
// Same number as the floor, so the popup is exactly the width it resolved
|
|
// to. The room the chosen side has is a clamp on that, never a licence to
|
|
// grow into it - the list is still allowed to be narrower than the room
|
|
// beside it, which is the normal case.
|
|
final popupMaxWidth = placement != null
|
|
? math.min(popupWidth, placement.maxWidth)
|
|
: popupWidth;
|
|
|
|
final enabled = widget.enabled ?? widget.onChanged != null;
|
|
final density = widget.density ?? ControlDensity.of(theme);
|
|
|
|
final handle = _SelectHandle(
|
|
isSelected: _isSelected,
|
|
selectItem: _selectItem,
|
|
hasSelection: widget.value != null,
|
|
enabled: enabled,
|
|
density: density,
|
|
);
|
|
|
|
final overlay = Overlay.of(context);
|
|
_entry = OverlayEntry(
|
|
builder: (ctx) {
|
|
return Stack(
|
|
children: [
|
|
// modal barrier — tap anywhere outside to dismiss.
|
|
Positioned.fill(
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
excludeFromSemantics: true,
|
|
onTap: _close,
|
|
child: const SizedBox.expand(),
|
|
),
|
|
),
|
|
CompositedTransformFollower(
|
|
link: _link,
|
|
showWhenUnlinked: false,
|
|
targetAnchor: placement?.targetAnchor ?? Alignment.bottomCenter,
|
|
followerAnchor: placement?.followerAnchor ?? Alignment.topCenter,
|
|
offset: placement?.offset ?? Offset(0, popupGap),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
minWidth: popupMaxWidth,
|
|
maxWidth: popupMaxWidth,
|
|
maxHeight: resolvedMaxHeight,
|
|
),
|
|
child: Focus(
|
|
autofocus: true,
|
|
onKeyEvent: (node, event) {
|
|
if (event is KeyDownEvent &&
|
|
event.logicalKey == LogicalKeyboardKey.escape) {
|
|
_close();
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
},
|
|
child: _SelectScope(
|
|
handle: handle,
|
|
child: Builder(builder: (c) => widget.popup(c)),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
overlay.insert(_entry!);
|
|
}
|
|
|
|
void _close() {
|
|
_entry?.remove();
|
|
_entry = null;
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant Select<T> oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
// if we got disabled while open, bail out of the popover.
|
|
final enabled = widget.enabled ?? widget.onChanged != null;
|
|
if (!enabled && _isOpen) _close();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_close();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = GarageTheme.of(context);
|
|
final cs = theme.colorScheme;
|
|
final enabled = widget.enabled ?? widget.onChanged != null;
|
|
final density = widget.density ?? ControlDensity.of(theme);
|
|
|
|
// secondary draws its content in secondaryForeground, same as
|
|
// Button.secondary - the fill is solid enough that plain `foreground`
|
|
// isnt the right pairing.
|
|
// same story as TextField - a row's control slot is always secondary.
|
|
final variant = PropertySlotScope.of(context)
|
|
? SelectVariant.secondary
|
|
: widget.variant;
|
|
final invalid = FieldErrorScope.of(context);
|
|
final enabledContentColor = variant == SelectVariant.secondary
|
|
? cs.secondaryForeground
|
|
: cs.foreground;
|
|
final textStyle = theme.typography.small.copyWith(
|
|
color: enabled ? enabledContentColor : cs.mutedForeground,
|
|
);
|
|
|
|
final showValue = widget.value != null;
|
|
|
|
Widget valueChild;
|
|
if (showValue) {
|
|
valueChild = widget.itemBuilder(context, widget.value as T);
|
|
} else if (widget.placeholder != null) {
|
|
// placeholder renders muted, same as shadcn's `.muted`.
|
|
valueChild = DefaultTextStyle.merge(
|
|
style: TextStyle(color: cs.mutedForeground),
|
|
child: widget.placeholder!,
|
|
);
|
|
} else {
|
|
valueChild = const SizedBox();
|
|
}
|
|
|
|
final chevronWidth = theme.iconTheme.small.size ?? 0;
|
|
final gap = theme.density.controlGap;
|
|
|
|
final leadingChild = showValue && widget.leadingBuilder != null
|
|
? widget.leadingBuilder!(context, widget.value as T)
|
|
: null;
|
|
|
|
final content = Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// each end carries the same width as the other - the mark and a
|
|
// chevron's worth of nothing on the left, the mark's worth of nothing
|
|
// and the chevron on the right. Thats what puts the value in the
|
|
// middle of the TRIGGER rather than in the middle of whats left over.
|
|
if (!widget.iconOnly) ...[
|
|
if (leadingChild != null) ...[leadingChild, SizedBox(width: gap)],
|
|
SizedBox(width: chevronWidth),
|
|
SizedBox(width: gap),
|
|
],
|
|
Expanded(
|
|
child: Align(alignment: Alignment.center, child: valueChild),
|
|
),
|
|
if (!widget.iconOnly) ...[
|
|
SizedBox(width: gap),
|
|
if (leadingChild != null) ...[
|
|
_MirrorOf(child: leadingChild),
|
|
SizedBox(width: gap),
|
|
],
|
|
Icon(
|
|
LucideIcons.chevrons_up_down,
|
|
// Match the compact icon token so the chevron does not drive the
|
|
// trigger taller than an equivalent button/field.
|
|
size: theme.iconTheme.small.size,
|
|
color: enabledContentColor.scaleAlpha(0.5),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
|
|
return MergeSemantics(
|
|
child: Semantics(
|
|
container: true,
|
|
button: true,
|
|
enabled: enabled,
|
|
expanded: _isOpen,
|
|
label: resolveSemanticLabel(context, widget.semanticLabel),
|
|
onTap: enabled ? _toggle : null,
|
|
child: _buildTrigger(
|
|
theme,
|
|
density,
|
|
enabled,
|
|
textStyle,
|
|
content,
|
|
variant,
|
|
invalid,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTrigger(
|
|
ThemeData theme,
|
|
ControlDensity density,
|
|
bool enabled,
|
|
TextStyle textStyle,
|
|
Widget content,
|
|
SelectVariant variant,
|
|
bool invalid,
|
|
) {
|
|
return IntrinsicWidth(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(),
|
|
child: FieldErrorOutline(
|
|
invalid: invalid,
|
|
// the same radius the three trigger decorations round themselves
|
|
// to, so the outline sits ON the border rather than beside it.
|
|
borderRadius: theme.borderRadiusMd,
|
|
child: CompositedTransformTarget(
|
|
link: _link,
|
|
child: _Btn(
|
|
key: _triggerKey,
|
|
enabled: enabled,
|
|
onPressed: enabled ? _toggle : null,
|
|
// the trigger IS a control - it goes through the exact same padding
|
|
// resolution as a bordered button, so a Select sitting next to a
|
|
// TextField or a Button.outline comes out the same height. it used
|
|
// to carry its own hand-tuned vertical insets and landed 19px
|
|
// compact / 29px normal against everything else's 23 / 27.
|
|
padding: resolveControlPadding(
|
|
theme,
|
|
density: density,
|
|
includesBorder: true,
|
|
squarePadding: widget.iconOnly,
|
|
),
|
|
alignment: AlignmentDirectional.centerStart,
|
|
cursor: SystemMouseCursors.click,
|
|
decoration: (hovered, disabled) => switch (variant) {
|
|
SelectVariant.outline => _outlineDecoration(
|
|
theme,
|
|
hovered: hovered,
|
|
disabled: disabled,
|
|
),
|
|
SelectVariant.secondary => _secondaryDecoration(
|
|
theme,
|
|
hovered: hovered,
|
|
disabled: disabled,
|
|
),
|
|
SelectVariant.ghost => _ghostDecoration(
|
|
theme,
|
|
hovered: hovered && !disabled,
|
|
),
|
|
},
|
|
textStyle: textStyle,
|
|
child: content,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Popup rows have their own height target - they sit tighter than the trigger
|
|
// on purpose. The vertical inset falls straight out of popupRowHeight, so the
|
|
// row lands on exactly that number and nobody has to re-tune a half pixel.
|
|
EdgeInsets _selectMenuPadding(ControlDensity density, ThemeData theme) {
|
|
final tokens = density.tokens(theme);
|
|
final vertical = tokens.popupRowPaddingY;
|
|
return density.resolve(theme).copyWith(top: vertical, bottom: vertical);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// the popover
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/// the popover surface for a [Select]. use the default constructor with a
|
|
/// static [SelectItemList], or [SelectPopup.builder] for a searchable list
|
|
/// built on demand from the query.
|
|
class SelectPopup<T> extends StatefulWidget {
|
|
/// static item delegate (default constructor).
|
|
final SelectItemDelegate? items;
|
|
|
|
/// on-demand item builder (`.builder` constructor). gets the search query.
|
|
final SelectItemsBuilder<T>? builder;
|
|
|
|
/// placeholder shown inside the search field.
|
|
final Widget? searchPlaceholder;
|
|
|
|
/// whether the search field is shown (only in builder mode).
|
|
final bool enableSearch;
|
|
|
|
/// whether the list shrink-wraps (static lists do, builder lists scroll).
|
|
final bool shrinkWrap;
|
|
|
|
const SelectPopup({
|
|
super.key,
|
|
this.items,
|
|
this.searchPlaceholder,
|
|
this.shrinkWrap = true,
|
|
}) : builder = null,
|
|
enableSearch = false;
|
|
|
|
const SelectPopup.builder({
|
|
super.key,
|
|
required this.builder,
|
|
this.searchPlaceholder,
|
|
}) : items = null,
|
|
enableSearch = true,
|
|
shrinkWrap = false;
|
|
|
|
/// lets a SelectPopup be handed straight to `popup:` (a SelectPopupBuilder).
|
|
Widget call(BuildContext context) => this;
|
|
|
|
@override
|
|
State<SelectPopup<T>> createState() => _SelectPopupState<T>();
|
|
}
|
|
|
|
class _SelectPopupState<T> extends State<SelectPopup<T>> {
|
|
final TextEditingController _searchController = TextEditingController();
|
|
final ScrollController _scrollController = ScrollController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchController.dispose();
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = GarageTheme.of(context);
|
|
|
|
final column = Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (widget.enableSearch) ...[
|
|
_SearchField(
|
|
controller: _searchController,
|
|
placeholder: widget.searchPlaceholder,
|
|
onChanged: (_) => setState(() {}),
|
|
),
|
|
Container(height: 1, color: theme.colorScheme.divider),
|
|
],
|
|
Flexible(
|
|
child: ListenableBuilder(
|
|
listenable: _searchController,
|
|
builder: (context, _) {
|
|
final query = _searchController.text.isEmpty
|
|
? null
|
|
: _searchController.text;
|
|
return _buildList(context, query);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
// No IntrinsicWidth. The width comes from the trigger now (see
|
|
// Select.popupWidth), so the popup is handed a tight cross axis and has
|
|
// nothing to measure - asking a column how wide it wants to be and then
|
|
// ignoring the answer is just a layout pass nobody reads.
|
|
//
|
|
// [shrinkWrap] still decides the VERTICAL: a static list is a scrolling
|
|
// Column that takes only the height it needs, the builder path is a lazy
|
|
// ListView.
|
|
return _popupSurface(theme, column);
|
|
}
|
|
|
|
Widget _buildList(BuildContext context, String? query) {
|
|
if (widget.builder != null) {
|
|
final result = widget.builder!(context, query);
|
|
if (result is Future<SelectItemDelegate?>) {
|
|
return FutureBuilder<SelectItemDelegate?>(
|
|
future: result,
|
|
builder: (context, snap) {
|
|
if (snap.connectionState == ConnectionState.waiting) {
|
|
return const SizedBox();
|
|
}
|
|
if (snap.hasError) {
|
|
// never swallow — surface it for debugging then show nothing.
|
|
debugPrint("SelectPopup builder failed: ${snap.error}");
|
|
debugPrintStack(stackTrace: snap.stackTrace);
|
|
return const SizedBox();
|
|
}
|
|
return _listView(context, snap.data);
|
|
},
|
|
);
|
|
}
|
|
return _listView(context, result);
|
|
}
|
|
return _listView(context, widget.items);
|
|
}
|
|
|
|
Widget _listView(BuildContext context, SelectItemDelegate? delegate) {
|
|
final theme = GarageTheme.of(context);
|
|
final count = delegate?.estimatedChildCount ?? 0;
|
|
if (delegate == null || count == 0) {
|
|
return const SizedBox();
|
|
}
|
|
final inset = _popupInset(theme);
|
|
|
|
// A static list is laid out as a COLUMN, not a ListView.
|
|
//
|
|
// A ListView is a scrollable: it takes every pixel of cross axis its
|
|
// handed and cannot report an intrinsic width, so a popup built on one
|
|
// either fills whatever ceiling it is given or has none and runs off the
|
|
// edge. Neither is "as wide as the widest option".
|
|
//
|
|
// A Column can say how wide it wants to be, and SingleChildScrollView
|
|
// passes that question straight through to it (its render object
|
|
// delegates computeMaxIntrinsicWidth to its child), so wrapping the pair
|
|
// in an IntrinsicWidth gives a popup that sizes to its rows AND still
|
|
// scrolls when there are too many to show.
|
|
//
|
|
// Only for the static path. The builder constructor sets shrinkWrap false
|
|
// and can be handed hundreds of rows - there, laying them all out to
|
|
// measure the widest is exactly what a ListView exists to avoid.
|
|
if (widget.shrinkWrap) {
|
|
return SingleChildScrollView(
|
|
controller: _scrollController,
|
|
padding: EdgeInsets.all(inset),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
for (var i = 0; i < count; i++) ...[
|
|
if (i > 0) Gap(_popupRowGap(theme)),
|
|
?delegate.build(context, i),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return ListView.separated(
|
|
controller: _scrollController,
|
|
padding: EdgeInsets.all(inset),
|
|
shrinkWrap: widget.shrinkWrap,
|
|
itemCount: count,
|
|
separatorBuilder: (context, _) => Gap(_popupRowGap(theme)),
|
|
itemBuilder: (context, index) =>
|
|
delegate.build(context, index) ?? const SizedBox(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// the padding between the surface's edge and the rows inside it. half the
|
|
/// container gap, same inset a dropdown menu gives its own list - these are
|
|
/// the same kind of surface so they shouldnt disagree.
|
|
double _popupInset(dynamic theme) =>
|
|
(theme.density.containerGap as double) * 0.5;
|
|
|
|
/// air between one row and the next.
|
|
///
|
|
/// They were flush, which reads as one block with lines drawn on it rather
|
|
/// than as a list of things you can point at - and the selected row, which is
|
|
/// a secondary button with a border, had its border sitting directly on its
|
|
/// neighbours.
|
|
double _popupRowGap(dynamic theme) => (theme.density.gapXxs as double);
|
|
|
|
// the popover surface — a port of ModalContainer -> Card -> OutlinedContainer.
|
|
// popover background, muted border, hard-edge clip, plus the theme's surface
|
|
// opacity/blur if it sets any. The fill matches MenuPopup so a select doesnt
|
|
// read as a different kind of surface.
|
|
Widget _popupSurface(dynamic theme, Widget child) {
|
|
final cs = theme.colorScheme;
|
|
final double borderWidth = theme.density.controlBorderWidth as double;
|
|
|
|
// Concentric with the rows, not the same as them.
|
|
//
|
|
// The surface used to take radiusMd, which is exactly what a row takes -
|
|
// so with the inset between them the outer corner turned inside a corner of
|
|
// the same curve, and the gap at 45 degrees was visibly tighter than the
|
|
// gap along the edges. An outer radius is its inner radius plus whatever
|
|
// sits between the two.
|
|
// less 2: concentric is the rule, but the full sum reads too soft on a
|
|
// surface this size - the corner starts curving before the first row does.
|
|
final BorderRadius radius = _growRadius(
|
|
theme.borderRadiusMd as BorderRadius,
|
|
_popupInset(theme) + borderWidth - 2,
|
|
);
|
|
|
|
var bg = cs.popover as Color;
|
|
final double? surfaceOpacity = theme.surfaceOpacity;
|
|
if (surfaceOpacity != null) {
|
|
bg = bg.scaleAlpha(surfaceOpacity);
|
|
}
|
|
|
|
final innerRadius = _subtractRadius(radius, borderWidth);
|
|
|
|
Widget surface = Container(
|
|
decoration: BoxDecoration(
|
|
color: bg,
|
|
border: Border.all(
|
|
color: cs.popoverBorder,
|
|
width: borderWidth,
|
|
strokeAlign: BorderSide.strokeAlignCenter,
|
|
),
|
|
borderRadius: radius,
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: innerRadius,
|
|
clipBehavior: Clip.hardEdge,
|
|
child: DefaultTextStyle.merge(
|
|
style: TextStyle(color: cs.foreground),
|
|
child: child,
|
|
),
|
|
),
|
|
);
|
|
|
|
final double? surfaceBlur = theme.surfaceBlur;
|
|
if (surfaceBlur != null && surfaceBlur > 0) {
|
|
surface = ClipRRect(
|
|
borderRadius: radius,
|
|
child: BackdropFilter(
|
|
filter: ui.ImageFilter.blur(sigmaX: surfaceBlur, sigmaY: surfaceBlur),
|
|
child: surface,
|
|
),
|
|
);
|
|
}
|
|
|
|
return surface;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// items
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/// a tappable option inside a [SelectPopup]. shows a check on the selected
|
|
/// one, and reserves the check column on the others so text stays aligned.
|
|
class SelectItemButton<T> extends StatelessWidget {
|
|
final T value;
|
|
final Widget child;
|
|
final bool? enabled;
|
|
|
|
/// The row's mark - a swatch, a type icon - pinned to the start of the row.
|
|
/// Pass it here rather than building it into [child]: see [_Btn.leading].
|
|
final Widget? leading;
|
|
|
|
const SelectItemButton({
|
|
super.key,
|
|
required this.value,
|
|
required this.child,
|
|
this.enabled,
|
|
this.leading,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = GarageTheme.of(context);
|
|
final cs = theme.colorScheme;
|
|
// controlGap - this is the row's label to check-icon gap, same as a button's.
|
|
final densityGap = theme.density.controlGap;
|
|
|
|
final handle = _SelectScope.maybeOf(context);
|
|
final selected = handle?.isSelected(value) ?? false;
|
|
final hasSelection = handle?.hasSelection ?? false;
|
|
final en = enabled ?? true;
|
|
|
|
// the selected row reads as a secondary button, so its label takes the
|
|
// secondary foreground rather than the popover's.
|
|
final textStyle = theme.typography.small.copyWith(
|
|
color: selected
|
|
? cs.secondaryForeground
|
|
: (en ? cs.foreground : cs.mutedForeground),
|
|
);
|
|
|
|
Widget? trailing;
|
|
if (selected) {
|
|
// same optical nudge every control puts on its content, so the check
|
|
// tracks the label beside it. it used to be a hardcoded 2, which sat the
|
|
// check a clear 2px under the row centre.
|
|
final nudge = theme.density.controlTextOffset;
|
|
final check = Icon(
|
|
LucideIcons.check,
|
|
size: theme.iconTheme.small.size,
|
|
color: textStyle.color,
|
|
);
|
|
trailing = nudge == 0
|
|
? check
|
|
: Transform.translate(offset: Offset(0, nudge), child: check);
|
|
} else if (hasSelection) {
|
|
trailing = SizedBox(width: theme.iconTheme.small.size);
|
|
}
|
|
|
|
return MergeSemantics(
|
|
child: Semantics(
|
|
container: true,
|
|
inMutuallyExclusiveGroup: true,
|
|
selected: selected,
|
|
enabled: en,
|
|
onTap: en ? () => handle?.selectItem(value, !selected) : null,
|
|
child: _row(
|
|
context,
|
|
theme,
|
|
en,
|
|
handle,
|
|
selected,
|
|
trailing,
|
|
densityGap,
|
|
textStyle,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _row(
|
|
BuildContext context,
|
|
ThemeData theme,
|
|
bool en,
|
|
_SelectHandle? handle,
|
|
bool selected,
|
|
Widget? trailing,
|
|
double densityGap,
|
|
TextStyle textStyle,
|
|
) {
|
|
return _Btn(
|
|
enabled: en,
|
|
onPressed: en ? () => handle?.selectItem(value, !selected) : null,
|
|
padding: _selectMenuPadding(
|
|
handle?.density ?? ControlDensity.compact,
|
|
theme,
|
|
),
|
|
// centred, like every other select in the app - see the trigger. The
|
|
// check column is mirrored on the left by leadingWidth so the label
|
|
// sits in the middle of the ROW rather than in the middle of whats
|
|
// left of it.
|
|
alignment: Alignment.center,
|
|
cursor: SystemMouseCursors.basic,
|
|
disableTransition: true,
|
|
trailing: trailing,
|
|
trailingGap: densityGap,
|
|
leadingWidth: trailing == null ? 0 : theme.iconTheme.small.size ?? 0,
|
|
leading: leading,
|
|
// The selected row IS a secondary button - the same helper the
|
|
// secondary trigger uses, border and all, rather than a fill that
|
|
// merely looks like one.
|
|
//
|
|
// A check in the margin says which row is selected; a filled row says it
|
|
// without being read, which in a list of ten is the difference between
|
|
// finding the current value and scanning for a tick.
|
|
decoration: (hovered, disabled) => selected
|
|
? _secondaryDecoration(theme, hovered: hovered, disabled: disabled)
|
|
: _ghostDecoration(theme, hovered: hovered && !disabled),
|
|
textStyle: textStyle,
|
|
child: child,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Holds exactly as much width as [child] would take, and draws nothing.
|
|
///
|
|
/// The trigger balances whatever sits at its end with one of these at its
|
|
/// start, so a centred label is centred in the control. Measuring the real
|
|
/// widget rather than guessing a number means a swatch, an icon and a two
|
|
/// character badge all balance correctly without anyone maintaining a table
|
|
/// of widths.
|
|
class _MirrorOf extends StatelessWidget {
|
|
const _MirrorOf({required this.child});
|
|
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Visibility(
|
|
visible: false,
|
|
maintainSize: true,
|
|
maintainAnimation: true,
|
|
maintainState: true,
|
|
child: IgnorePointer(child: ExcludeSemantics(child: child)),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// a value-carrying wrapper that reports its selected state to descendants.
|
|
/// kept for API parity — the app builds items with [SelectItemButton] instead,
|
|
/// but this mirrors shadcn's SelectItem.
|
|
class SelectItem extends StatelessWidget {
|
|
final WidgetBuilder builder;
|
|
final Object? value;
|
|
|
|
const SelectItem({super.key, required this.value, required this.builder});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Builder(builder: builder);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// item delegates
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/// how a popup gets its items — either a fixed list ([SelectItemList]) or,
|
|
/// via [SelectPopup.builder], a freshly built one per search query.
|
|
abstract class SelectItemDelegate {
|
|
const SelectItemDelegate();
|
|
|
|
Widget? build(BuildContext context, int index);
|
|
|
|
int? get estimatedChildCount => null;
|
|
}
|
|
|
|
/// the common case: a static list of [SelectItemButton]s.
|
|
class SelectItemList extends SelectItemDelegate {
|
|
final List<Widget> children;
|
|
|
|
const SelectItemList({required this.children});
|
|
|
|
@override
|
|
Widget build(BuildContext context, int index) => children[index];
|
|
|
|
@override
|
|
int get estimatedChildCount => children.length;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// the little stateful button both the trigger and items are built on
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
class _Btn extends StatefulWidget {
|
|
final Widget child;
|
|
final Widget? trailing;
|
|
final double trailingGap;
|
|
|
|
/// Reserves the same width on the LEFT as [trailing] takes on the right, so
|
|
/// a centre-aligned child is centred in the control rather than in whats
|
|
/// left of it after the check/chevron.
|
|
final double leadingWidth;
|
|
|
|
/// Pinned to the start of the row, with its width mirrored at the end.
|
|
///
|
|
/// The mark that identifies a value - a swatch, a type icon. Rows put it
|
|
/// here rather than inline with the label for the same reason the trigger
|
|
/// does: a centred "swatch + label" pair walks its swatch sideways with
|
|
/// every change of label length, and a list of ten of those has no left
|
|
/// edge at all.
|
|
final Widget? leading;
|
|
final VoidCallback? onPressed;
|
|
final bool enabled;
|
|
final EdgeInsetsGeometry padding;
|
|
final AlignmentGeometry alignment;
|
|
final bool disableTransition;
|
|
final MouseCursor cursor;
|
|
final BoxDecoration Function(bool hovered, bool disabled) decoration;
|
|
final TextStyle textStyle;
|
|
|
|
const _Btn({
|
|
super.key,
|
|
required this.child,
|
|
this.trailing,
|
|
this.trailingGap = 0,
|
|
this.leadingWidth = 0,
|
|
this.leading,
|
|
required this.onPressed,
|
|
required this.enabled,
|
|
required this.padding,
|
|
required this.alignment,
|
|
this.disableTransition = false,
|
|
required this.cursor,
|
|
required this.decoration,
|
|
required this.textStyle,
|
|
});
|
|
|
|
@override
|
|
State<_Btn> createState() => _BtnState();
|
|
}
|
|
|
|
class _BtnState extends State<_Btn> {
|
|
bool _hovered = false;
|
|
bool _focused = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final disabled = !widget.enabled;
|
|
var deco = widget.decoration(
|
|
(_hovered || _focused) && widget.enabled,
|
|
disabled,
|
|
);
|
|
|
|
// same corner-squaring + border-merge Button already does (button.dart) -
|
|
// without this a Select sitting in a ButtonGroup (see object_field.dart)
|
|
// keeps its own full radius and a doubled-up border against its
|
|
// neighbour instead of reading as one connected control
|
|
final group = ButtonGroupScope.maybeOf(context);
|
|
if (group != null) {
|
|
deco = deco.copyWith(
|
|
borderRadius: group.corners.applyTo(
|
|
deco.borderRadius ?? BorderRadius.zero,
|
|
Directionality.of(context),
|
|
),
|
|
);
|
|
if (deco.border case final Border border) {
|
|
deco = deco.copyWith(
|
|
border: group.mergedBorder(border, Directionality.of(context)),
|
|
);
|
|
}
|
|
}
|
|
|
|
final leading = widget.leading;
|
|
|
|
Widget inner;
|
|
if (widget.trailing != null || leading != null) {
|
|
inner = IntrinsicWidth(
|
|
child: IntrinsicHeight(
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// both ends carry the same width, so the child is centred in the
|
|
// ROW rather than in what the mark and the check left of it.
|
|
if (leading != null) ...[
|
|
leading,
|
|
SizedBox(width: widget.trailingGap),
|
|
],
|
|
if (widget.leadingWidth > 0) ...[
|
|
SizedBox(width: widget.leadingWidth),
|
|
SizedBox(width: widget.trailingGap),
|
|
],
|
|
Expanded(
|
|
child: Align(alignment: widget.alignment, child: widget.child),
|
|
),
|
|
if (widget.trailing != null) ...[
|
|
SizedBox(width: widget.trailingGap),
|
|
if (leading != null) ...[
|
|
_MirrorOf(child: leading),
|
|
SizedBox(width: widget.trailingGap),
|
|
],
|
|
widget.trailing!,
|
|
] else if (leading != null) ...[
|
|
SizedBox(width: widget.trailingGap),
|
|
_MirrorOf(child: leading),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
} else {
|
|
inner = widget.child;
|
|
}
|
|
|
|
// plain Container - no colour easing on hover, matching Button and
|
|
// TextField. see the note in button.dart.
|
|
final content = Container(
|
|
padding: widget.padding,
|
|
decoration: deco,
|
|
child: DefaultTextStyle.merge(
|
|
style: widget.textStyle,
|
|
child: IconTheme.merge(
|
|
data: IconThemeData(color: widget.textStyle.color),
|
|
child: inner,
|
|
),
|
|
),
|
|
);
|
|
|
|
return MouseRegion(
|
|
// forbidden when its off, not the plain arrow. A disabled select looks
|
|
// much like an enabled one holding a value, so the pointer is the first
|
|
// thing that tells you it wont open - before you click and nothing
|
|
// happens.
|
|
cursor: widget.enabled ? widget.cursor : SystemMouseCursors.forbidden,
|
|
onEnter: (_) => setState(() => _hovered = true),
|
|
onExit: (_) => setState(() => _hovered = false),
|
|
// this used to be pointer-only, so a Select trigger and every row in
|
|
// its popup were unreachable by keyboard - you could see them, tab
|
|
// straight past them, and never open one. Button has had this since
|
|
// forever; the select family just never got it.
|
|
child: FocusableActionDetector(
|
|
enabled: widget.enabled,
|
|
shortcuts: const {
|
|
SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(),
|
|
SingleActivator(LogicalKeyboardKey.space): ActivateIntent(),
|
|
},
|
|
actions: {
|
|
ActivateIntent: CallbackAction<ActivateIntent>(
|
|
onInvoke: (_) {
|
|
widget.onPressed?.call();
|
|
return null;
|
|
},
|
|
),
|
|
},
|
|
onShowFocusHighlight: (v) {
|
|
if (mounted) setState(() => _focused = v);
|
|
},
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
excludeFromSemantics: true,
|
|
onTap: widget.enabled ? widget.onPressed : null,
|
|
child: content,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// decorations (ports of shadcn's outline + ghost button variances)
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
// no strokeAlign override on any of these - it defaults to inside, same as the
|
|
// TextField and Button.outline borders. Center-aligned (the old value here)
|
|
// only feeds Container half the stroke per side, so the trigger came out a
|
|
// pixel shorter than a field with identical padding, and painted the other half
|
|
// of the stroke outside its own bounds. button.dart hit this too.
|
|
// Outline trigger. Same tokens TextField's own decoration and Button.outline
|
|
// use (controlFill/Hovered + controlBorder + radiusMd), so a Select sitting
|
|
// in a row of fields reads as the same control family. It used to run
|
|
// input.scaleAlpha(0.3) over cs.border - shadcn-migration leftovers that gave
|
|
// it a fill and a stroke nothing else in the app drew.
|
|
BoxDecoration _outlineDecoration(
|
|
dynamic theme, {
|
|
required bool hovered,
|
|
required bool disabled,
|
|
}) {
|
|
final cs = theme.colorScheme;
|
|
final radius = theme.borderRadiusMd as BorderRadius;
|
|
final width = theme.density.controlBorderWidth as double;
|
|
if (disabled) {
|
|
return BoxDecoration(
|
|
color: cs.controlFill,
|
|
border: Border.all(color: cs.controlBorder, width: width),
|
|
borderRadius: radius,
|
|
);
|
|
}
|
|
return BoxDecoration(
|
|
color: hovered ? cs.controlFillHovered : cs.controlFill,
|
|
border: Border.all(color: cs.controlBorder, width: width),
|
|
borderRadius: radius,
|
|
);
|
|
}
|
|
|
|
// Secondary trigger - the Select equivalent of Button.secondary. Same
|
|
// secondary fill + controlBorder stroke that button draws, so the two are
|
|
// interchangeable in a toolbar. Hover comes off the scheme's secondaryHovered,
|
|
// the same token button reads.
|
|
BoxDecoration _secondaryDecoration(
|
|
dynamic theme, {
|
|
required bool hovered,
|
|
required bool disabled,
|
|
}) {
|
|
final cs = theme.colorScheme;
|
|
final radius = theme.borderRadiusMd as BorderRadius;
|
|
final width = theme.density.controlBorderWidth as double;
|
|
return BoxDecoration(
|
|
color: (hovered && !disabled)
|
|
? cs.secondaryHovered as Color
|
|
: cs.secondary as Color,
|
|
border: Border.all(color: cs.controlBorder, width: width),
|
|
borderRadius: radius,
|
|
);
|
|
}
|
|
|
|
BoxDecoration _ghostDecoration(dynamic theme, {required bool hovered}) {
|
|
final cs = theme.colorScheme;
|
|
final radius = theme.borderRadiusMd as BorderRadius;
|
|
if (hovered) {
|
|
return BoxDecoration(color: cs.popoverItemHovered, borderRadius: radius);
|
|
}
|
|
return BoxDecoration(
|
|
color: (cs.muted as Color).withValues(alpha: 0),
|
|
borderRadius: radius,
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// search field (builder popups)
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
class _SearchField extends StatefulWidget {
|
|
final TextEditingController controller;
|
|
final Widget? placeholder;
|
|
final ValueChanged<String> onChanged;
|
|
|
|
const _SearchField({
|
|
required this.controller,
|
|
required this.placeholder,
|
|
required this.onChanged,
|
|
});
|
|
|
|
@override
|
|
State<_SearchField> createState() => _SearchFieldState();
|
|
}
|
|
|
|
class _SearchFieldState extends State<_SearchField> {
|
|
final FocusNode _focusNode = FocusNode();
|
|
|
|
@override
|
|
void dispose() {
|
|
_focusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = GarageTheme.of(context);
|
|
final cs = theme.colorScheme;
|
|
final style = theme.typography.small.copyWith(color: cs.foreground);
|
|
final showPlaceholder =
|
|
widget.controller.text.isEmpty && widget.placeholder != null;
|
|
|
|
// the search row is a control, so it takes the control tokens rather than
|
|
// the 12/16/8 literals it was ported with - those didnt move with density
|
|
// at all, so the row stayed the same height while every control around it
|
|
// changed.
|
|
final density = theme.density;
|
|
return Padding(
|
|
padding: EdgeInsets.symmetric(
|
|
vertical: density.controlPaddingY,
|
|
horizontal: density.buttonPaddingX,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
LucideIcons.search,
|
|
size: theme.iconTheme.small.size,
|
|
color: cs.mutedForeground,
|
|
),
|
|
SizedBox(width: density.controlGap),
|
|
Expanded(
|
|
child: Stack(
|
|
alignment: AlignmentDirectional.centerStart,
|
|
children: [
|
|
if (showPlaceholder)
|
|
DefaultTextStyle.merge(
|
|
style: TextStyle(color: cs.mutedForeground),
|
|
child: widget.placeholder!,
|
|
),
|
|
EditableText(
|
|
controller: widget.controller,
|
|
focusNode: _focusNode,
|
|
autofocus: true,
|
|
style: style,
|
|
cursorColor: cs.primary,
|
|
backgroundCursorColor: cs.muted,
|
|
selectionColor: cs.primary.scaleAlpha(0.3),
|
|
onChanged: (v) {
|
|
setState(() {});
|
|
widget.onChanged(v);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// clamps each corner radius down by the border width, like shadcn's
|
|
// subtractByBorder so the inner clip sits flush inside the stroke.
|
|
/// the other direction: a corner that wraps [w] of padding around [r].
|
|
BorderRadius _growRadius(BorderRadius r, double w) {
|
|
Radius add(Radius a) => Radius.elliptical(a.x + w, a.y + w);
|
|
return BorderRadius.only(
|
|
topLeft: add(r.topLeft),
|
|
topRight: add(r.topRight),
|
|
bottomLeft: add(r.bottomLeft),
|
|
bottomRight: add(r.bottomRight),
|
|
);
|
|
}
|
|
|
|
BorderRadius _subtractRadius(BorderRadius r, double w) {
|
|
Radius sub(Radius a) => Radius.elliptical(
|
|
(a.x - w).clamp(0.0, double.infinity),
|
|
(a.y - w).clamp(0.0, double.infinity),
|
|
);
|
|
return BorderRadius.only(
|
|
topLeft: sub(r.topLeft),
|
|
topRight: sub(r.topRight),
|
|
bottomLeft: sub(r.bottomLeft),
|
|
bottomRight: sub(r.bottomRight),
|
|
);
|
|
}
|