Files
Garage-SDKs/garage_ui/lib/properties.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

941 lines
37 KiB
Dart

import "package:flutter/gestures.dart";
import "package:flutter/widgets.dart";
import "package:flutter_lucide/flutter_lucide.dart";
import "context_menu.dart";
import "field_error.dart";
import "surface.dart";
import "semantics_scope.dart";
import "menu.dart";
import "theme/colour_scheme.dart";
import "theme/garage_theme.dart";
/// Where a [PropertyRow] splits label from control, as a fraction of the
/// row's width.
///
/// Blender doesnt give the label a fixed column - the split tracks the panel,
/// which is why the label/control pair always reads as centred no matter how
/// wide the editor gets. 0.4 is the split factor Blender itself defaults to
/// for `use_property_split` layouts.
const double kPropertySplitFactor = 0.4;
/// Gap between a property row's label column and its control.
const double kPropertyLabelGap = 10.0;
/// One property row: right-aligned label up to the split, control taking the
/// rest. Matches Blender's "Location X |" layout.
///
/// This is the app's form field - a labelled row that goes inside a
/// [PropertiesSection], with an optional right-click reset/copy/paste menu
/// via [PropertyActions].
class PropertyRow extends StatelessWidget {
const PropertyRow({
super.key,
required this.label,
required this.scheme,
required this.child,
this.subtitle,
this.description,
this.error,
this.action,
this.actions,
this.split = kPropertySplitFactor,
this.labelless = false,
});
final String label;
final ColourScheme scheme;
final Widget child;
/// Second line under the label, INSIDE the label column - so it stays right
/// aligned against the split the way the label is. For naming the value
/// ("Used for account notifications"), not explaining it.
final String? subtitle;
/// Full width line under the whole row, spanning the label AND the control.
/// For copy about the setting rather than about the field - consequences,
/// caveats, what changes when you change it.
final String? description;
/// Why the value was refused. Reddens the control's outline and says why
/// underneath it, in place of a toast that would float away from the field
/// it was complaining about.
final String? error;
/// Beside the label, on its line - the same slot [SettingsRow] has, and the
/// onboarding flow's ProductField before it. A muted "Required" or
/// "Optional", usually.
///
/// Styled here, at the subtitle's size and colour, so it reads as a tag on
/// the label rather than a second label. A caller setting its own style
/// still wins.
///
/// Not to be confused with [actions], which is the right-click menu.
final Widget? action;
final PropertyActions? actions;
/// fraction of the row width sitting left of the split. 0.5 would put the
/// control's left edge dead centre.
final double split;
final bool labelless;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final density = theme.density;
final captionStyle = TextStyle(
fontSize: density.textXxs,
color: scheme.mutedForeground,
);
final row = Container(
// a MINIMUM, not a fixed height. rows holding a single control already
// measure controlHeight on their own; this is what stops a plain-Text
// row (Type, Collection) collapsing to its ~11px line box and reading
// as a different rhythm to the field rows above it. rows that are
// legitimately taller - Size stacks two fields in a ButtonGroup - grow
// past it untouched, which a fixed height would squash.
//
// It sits a step ABOVE controlHeight on purpose. The row's content is
// not always the control: a label plus a subtitle is laid out on the
// font's own metrics and comes out ~25 against a 23px compact control,
// so at plain controlHeight the LABEL set the row height and the
// control went along with it - backwards, and it moved with whatever
// font resolved. With the minimum a little above both, neither one
// drives it and the row is the same height either way.
constraints: BoxConstraints(minHeight: density.propertyRowHeight),
padding: density.buttonPadding.copyWith(top: 0, bottom: 0),
// the content shrink-wraps and is centred in that minimum rather than
// being stretched to fill it, so a taller row grows symmetrically
// instead of hanging off the top.
child: Align(
alignment: Alignment.center,
heightFactor: 1.0,
child: Builder(
builder: (context) {
if (labelless) {
return PropertySlotScope(
child: FieldErrorScope(
invalid: error != null,
child: PropertyLabelScope(label: label, child: child),
),
);
}
// A flex split, NOT a LayoutBuilder. LayoutBuilder builds its child
// DURING layout, and an EditableText in that child marks itself
// needing layout as it builds - which trips
// _debugRelayoutBoundaryAlreadyMarkedNeedsLayout and takes the whole
// subtree down with a focus-scope assert behind it. Only shows up
// once a row holding a TextField is rebuilt mid-frame (a route swap
// under it was enough), which is why the inspector never hit it.
//
// Geometry is unchanged: the gap comes out of the LABEL'S PADDING
// rather than its width, so the label's text still ends at
// `width * split - gap` and the control's left edge still lands
// exactly on the split.
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
flex: (split * 1000).round(),
child: Padding(
padding: const EdgeInsetsDirectional.only(
end: kPropertyLabelGap,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
if (action == null)
Text(
label,
textAlign: TextAlign.right,
style: TextStyle(color: scheme.rowText),
overflow: TextOverflow.ellipsis,
)
else
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
label,
textAlign: TextAlign.right,
style: TextStyle(color: scheme.rowText),
overflow: TextOverflow.ellipsis,
),
),
SizedBox(width: density.gapXs),
DefaultTextStyle.merge(
style: captionStyle,
child: action!,
),
],
),
if (subtitle != null)
Text(
subtitle!,
textAlign: TextAlign.right,
style: captionStyle,
),
],
),
),
),
// the row's label is the control's name as far as a screen
// reader is concerned - publish it so the control can pick it
// up instead of announcing itself as an anonymous checkbox.
Expanded(
flex: ((1 - split) * 1000).round(),
child: PropertySlotScope(
child: FieldErrorScope(
invalid: error != null,
child: PropertyLabelScope(label: label, child: child),
),
),
),
],
);
},
),
),
);
final complaint = error;
final description = this.description;
// ALWAYS the column, even with nothing under the row - same as
// settings_list.dart. A row that changes SHAPE when it gains a line
// under it puts a different widget type at that position, so Flutter
// throws the subtree away and builds a new one, and that takes the
// CONTROL'S element with it. A control thats only just been built has
// nothing to animate from, so the outline turned up already red.
Widget content = Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
row,
if (description != null)
Padding(
// horizontal inset matches the row's, so the box's edges line
// up with the label column and the control above it rather
// than floating inside them.
padding: density.buttonPadding.copyWith(
top: density.gapXs,
bottom: 0,
),
child: OutlinedContainer(
// the outline field's own pair, not OutlinedContainer's
// defaults - a description sits among outline controls and
// should read as the same kind of surface. the default
// borderColor is `muted`, which on most schemes is close
// enough to the section fill to be invisible.
backgroundColor: scheme.controlFill,
borderColor: scheme.controlBorder,
borderWidth: density.controlBorderWidth,
// Md, matching the controls it sits under. the default is
// Xl, which next to a section at Sm reads as a pill.
borderRadius: theme.borderRadiusMd,
padding: EdgeInsets.symmetric(
horizontal: density.buttonPaddingX,
vertical: density.gapXs,
),
child: Text(
description,
textAlign: TextAlign.center,
style: captionStyle,
),
),
),
// under the control, where the control is - the outline says which
// field, this says what about it.
if (complaint != null)
Padding(
padding: density.buttonPadding.copyWith(
top: density.gapXs,
bottom: 0,
),
child: Text(
complaint,
textAlign: TextAlign.center,
style: captionStyle.copyWith(color: scheme.destructive),
),
),
],
);
// the row's height is what moves when a complaint arrives under it, so
// it eases rather than jumping. Anchored top, or the rows above the
// rejected one get shoved about by it.
content = AnimatedSize(
duration: kFieldErrorDuration,
curve: kFieldErrorCurve,
alignment: Alignment.topCenter,
child: content,
);
final actions = this.actions;
if (actions == null || !actions.hasAnyAction) return content;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onSecondaryTapDown: (details) {
showContextMenu(
context: context,
globalPosition: details.globalPosition,
onDismissed: () {},
items: [
MenuButton(
enabled: actions.canReset,
leading: const Icon(LucideIcons.rotate_ccw),
onPressed: (_) => actions.reset(),
child: const Text("Reset to Default"),
),
MenuButton(
enabled: actions.canCopy,
leading: const Icon(LucideIcons.copy),
onPressed: (_) => actions.copy(),
child: const Text("Copy"),
),
MenuButton(
enabled: actions.canPaste,
leading: const Icon(LucideIcons.clipboard_paste),
onPressed: (_) => actions.paste(),
child: const Text("Paste"),
),
],
);
},
child: content,
);
}
}
/// Context-menu behaviour for a property row. It intentionally carries a typed
/// value instead of raw text, so a copied colour cannot be pasted into a width
/// field just because both can be displayed as strings.
class PropertyActions<T> {
const PropertyActions({
required this.value,
required this.typeKey,
this.defaultValue,
this.onReset,
this.onPaste,
});
final T value;
final String typeKey;
final T? defaultValue;
final ValueChanged<T>? onReset;
final ValueChanged<T>? onPaste;
bool get canCopy => true;
bool get canReset => defaultValue != null && onReset != null;
bool get canPaste => onPaste != null && _PropertyClipboard.canPaste(typeKey);
bool get hasAnyAction => canCopy || canReset || onPaste != null;
void copy() {
_PropertyClipboard.copy(typeKey: typeKey, value: value);
}
void reset() {
final defaultValue = this.defaultValue;
if (defaultValue == null) return;
FocusManager.instance.primaryFocus?.unfocus();
onReset?.call(defaultValue);
}
void paste() {
final pasted = _PropertyClipboard.valueFor<T>(typeKey);
if (pasted == null) return;
FocusManager.instance.primaryFocus?.unfocus();
onPaste?.call(pasted);
}
}
class _PropertyClipboard {
static String? _typeKey;
static Object? _value;
static void copy({required String typeKey, required Object? value}) {
_typeKey = typeKey;
_value = value;
}
static bool canPaste(String typeKey) => _typeKey == typeKey;
static T? valueFor<T>(String typeKey) {
if (!canPaste(typeKey)) return null;
final value = _value;
if (value is! T) return null;
return value;
}
}
/// icon + name bar that sits above a properties panel's sections - blender's
/// "[icon] Cube" row. Use this for panels whose subject is fixed (a settings
/// panel, a canvas panel) - a panel whose identity changes with a live
/// selection (an object inspector) usually wants its own header instead, tied
/// to that selection.
class PanelHeader extends StatelessWidget {
const PanelHeader({
super.key,
required this.icon,
required this.title,
required this.scheme,
this.trailing,
this.bottomPadding = 8,
this.titleWidget,
});
final IconData icon;
final String title;
final ColourScheme scheme;
// optional row of action widgets (icon buttons, usually) after the title -
// only the agent panel needs this so far (copy debug json / clear), every
// other PanelHeader call site just leaves it null and gets the old layout.
final Widget? trailing;
// replaces the plain Text(title) in the middle slot when given - the agent
// panel uses this for its thread switcher. [title] is still required even
// then; its what a screen reader/tooltip falls back to and keeps every
// other call site simple (they never pass this at all).
final Widget? titleWidget;
// a panel that fades its own content in under this header (ScrollEdgeFade)
// wants that whitespace living INSIDE the fade zone instead of sitting
// above it as dead space the fade never touches - pass 0 here and put the
// same gap back as a SizedBox ahead of the faded child. Every other call
// site just takes the default and looks exactly as before.
final double bottomPadding;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(10, 10, 10, bottomPadding),
child: Row(
children: [
// 18px slot round a 12px glyph - same icon column the explorer rows
// use, so the panels all line up down the left edge.
SizedBox(
width: 18,
child: Center(
child: Icon(icon, size: 12, color: scheme.foreground),
),
),
const SizedBox(width: 2),
Expanded(
// header:true so a screen reader can jump panel to panel by
// heading instead of walking every control in between. The label
// is [title] even when titleWidget replaces the text, which is
// what that field's doc comment already promised.
child: Semantics(
header: true,
label: title,
child:
titleWidget ??
Text(
title,
style: GarageTheme.of(
context,
).typography.medium.copyWith(color: scheme.foreground),
overflow: TextOverflow.ellipsis,
),
),
),
if (trailing != null) trailing!,
],
),
);
}
}
/// Shared collapsible section treatment for Blender-style properties panels -
/// the app's version of a form group. Holds a title bar (tap to collapse) and
/// a list of [PropertyRow]s (or any other rows).
class PropertiesSection extends StatelessWidget {
const PropertiesSection({
super.key,
required this.title,
required this.scheme,
required this.collapsed,
required this.onToggle,
required this.rows,
this.subtitle,
this.trailing,
this.actions = const [],
});
final String title;
final ColourScheme scheme;
final bool collapsed;
final VoidCallback onToggle;
final List<Widget> rows;
/// Second line under the title, inside the header bar - the section's own
/// version of [PropertyRow.subtitle]. Names what the section holds. Stays
/// visible while collapsed, because it is part of the section's identity
/// rather than part of its body.
final String? subtitle;
/// What the section can DO, as opposed to what it holds - a Save, a Reset, a
/// Delete. Lives in a band at the bottom, separated from the rows and toned
/// off them, so it reads as the section acting on itself rather than as one
/// more property that happens to be a button.
///
/// Toned between the section's own fill and `muted` rather than sat on
/// `muted` itself. Straight muted is background - 7.4 while the section is
/// background + 5.1, so the band was landing twelve points under the card it
/// belongs to and within three of the editor's chrome - a hole cut through
/// the pane rather than a floor under the rows. Mixed, it lands within about
/// a point of whatever surface the section is sitting ON in every scheme we
/// ship, which is what a footer reads as. Collapses with the rows - a
/// collapsed section shows nothing but its title bar.
/// Sits at the far end of the header bar, level with the title.
///
/// For saying something ABOUT the section rather than doing something to it
/// - a status, a badge, a count. Actions belong in [actions], which has its
/// own band under the rows; this stays visible while the section is
/// collapsed, because whatever it says is part of how you recognise the
/// section in a list of them.
final Widget? trailing;
final List<Widget> actions;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final density = theme.density;
final radius = BorderRadius.circular(theme.radiusSm);
// null unless a PropertiesList is above us. Sections work standalone and
// always have; this is the only thing that changes when one isnt.
final reorder = PropertiesReorderScope.maybeOf(context);
final captionStyle = TextStyle(
fontSize: density.textXxs,
color: scheme.mutedForeground,
);
return Container(
// bottom is gapLg, not gapXs: this margin is what separates one section
// from the NEXT one, and gapXs is the step used between ROWS INSIDE a
// section - so at gapXs a run of sections read as one striped block
// rather than as separate subjects, which is the whole point of giving
// each one its own. Three times the inner step, so the boundary between
// two sections is unambiguously bigger than the boundary between two
// rows. The sides stay gapXs; thats an inset from the pane edge, a
// different job.
margin: EdgeInsets.fromLTRB(
density.gapXs,
0,
density.gapXs,
density.gapLg,
),
decoration: BoxDecoration(
color: scheme.card,
border: Border.all(color: scheme.propertiesSectionBorder),
borderRadius: radius,
),
child: ClipRRect(
borderRadius: radius,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MergeSemantics(
child: Semantics(
container: true,
button: true,
header: true,
expanded: !collapsed,
onTap: onToggle,
child: GestureDetector(
onTap: onToggle,
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
child: Container(
padding: theme.density.buttonPadding,
child: Row(
children: [
AnimatedRotation(
turns: collapsed ? -0.25 : 0.0,
duration: const Duration(milliseconds: 120),
child: Icon(
LucideIcons.chevron_down,
size: theme.iconTheme.small.size,
color: scheme.mutedForeground,
),
),
SizedBox(width: density.gapSm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: theme.typography.semiBold.copyWith(
color: scheme.rowText,
),
),
if (subtitle != null)
Text(subtitle!, style: captionStyle),
],
),
),
if (trailing != null) ...[
SizedBox(width: density.gapSm),
trailing!,
],
// The grab handle, when this section is inside a
// PropertiesList that lets it move. Last in the row,
// on the far edge - the left of the header belongs to
// the chevron, and two icons stacked there read as one
// control with two halves.
//
// Nothing at all when it cant move - not a disabled
// icon, not reserved space - so a page that never
// reorders looks exactly as it did before any of this
// existed.
if (reorder != null && reorder.draggable) ...[
SizedBox(width: density.gapSm),
_SectionDragHandle(
slot: reorder.slot,
child: MouseRegion(
cursor: SystemMouseCursors.grab,
// grip_horizontal: three across, two down. The
// list reorders VERTICALLY, and a grip whose
// rows run the same way as the travel is the one
// that reads as "drag me up and down".
//
// medium, not small - at the control icon size
// six dots turn into a smudge before they read
// as a texture you can grab.
//
// And well under mutedForeground, which is the
// colour of text you are meant to READ. A handle
// isnt read, its found - it only has to be there
// when you look for it, and at full muted it was
// competing with the section's own subtitle.
// Theres no token below muted, so this is muted
// taken down rather than a surface colour
// borrowed for a foreground job.
child: Icon(
LucideIcons.grip_horizontal,
size: theme.iconTheme.medium.size,
color: scheme.mutedForeground.withValues(
alpha: 0.25,
),
),
),
),
],
],
),
),
),
),
),
if (!collapsed) ...[
for (var i = 0; i < rows.length; i++) ...[
if (i > 0) SizedBox(height: density.gapXs),
rows[i],
],
// deliberately bigger than the between-rows step - the last row
// was sitting right on the section's bottom edge.
SizedBox(height: density.gapMd),
if (actions.isNotEmpty) ...[
Divider(color: scheme.propertiesSectionBorder),
Container(
// most of the way to muted, not all of it - see [actions].
// Lerped off the section's own fill so it tracks whatever
// the section is filled with instead of being pinned to a
// token two surfaces below it.
color: Color.lerp(scheme.card, scheme.muted, 0.45),
padding: EdgeInsets.symmetric(
horizontal: density.buttonPaddingX,
vertical: density.gapSm,
),
// the Row takes the full width, which is what makes the band
// span edge to edge rather than shrink to its buttons.
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
for (var i = 0; i < actions.length; i++) ...[
if (i > 0) SizedBox(width: density.gapSm),
actions[i],
],
],
),
),
],
],
],
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// reorderable lists of sections
// ─────────────────────────────────────────────────────────────────────────
/// One section in a [PropertiesList].
///
/// The flags live here rather than on [PropertiesSection] because a call site
/// almost never hands the list a bare section - it hands it a widget of its
/// own that renders one inside (`_GrantSection`, `_SubscriptionSection`). The
/// list cant read a field off somebody elses subtree, so what it needs to know
/// about an entry has to be said where the list can see it.
/// Which end of the list a pinned section is held at.
///
/// A roles list wants both: Owner at the top, Everyone at the bottom, and
/// neither of them anywhere else. A bool could only ever say "top", so the
/// section that belongs last ended up second from first.
enum PropertiesPin { top, bottom }
class PropertiesEntry {
const PropertiesEntry({
required this.id,
required this.child,
this.pin,
this.movable = true,
});
/// Stable across rebuilds, and what [PropertiesList.onReorder] reports.
///
/// NOT the position. The set changes under you - a grant revoked, a project
/// added - and an order stored as indices quietly means something else the
/// next time the list is a different length.
final String id;
final Widget child;
/// Held at one end of the list, and never dragged.
///
/// null - the default - means it moves with everything else. A pinned
/// section is rendered outside the reorderable list entirely, which is what
/// stops the list shifting it aside mid-drag and then correcting the drop.
final PropertiesPin? pin;
bool get pinned => pin != null;
/// Can this one be picked up? Default true - everything moves unless said
/// otherwise.
///
/// This is about the HANDLE, not the slot: an unmovable section cant be
/// dragged, but the ones around it can still move past it, so its index can
/// change. If a section has to stay put, [pinned] is the flag for that.
final bool movable;
}
/// A column of [PropertiesSection]s the user can drag into their own order.
///
/// It reports the order and stores nothing. Persisting it belongs to the
/// consumer - the kit has no business knowing where an app keeps preferences,
/// and one storage abstraction serving four call sites is exactly the thing it
/// shouldnt grow.
class PropertiesList extends StatefulWidget {
const PropertiesList({
super.key,
required this.entries,
required this.onReorder,
});
final List<PropertiesEntry> entries;
/// The ids, in the order they now sit in, pinned ones included.
///
/// The whole order rather than (oldIndex, newIndex): what a consumer stores
/// IS an order, and turning a pair of indices back into one is the same
/// dozen lines at every call site.
final void Function(List<String> order) onReorder;
@override
State<PropertiesList> createState() => _PropertiesListState();
}
class _PropertiesListState extends State<PropertiesList> {
/// One slot per entry id, kept for the life of the list.
///
/// The point is the IDENTITY, not the contents. A slot's index is written
/// in place as things move, so the scope handing it to a section never
/// changes and the section never rebuilds - which is what makes a drag
/// animate offsets instead of reconstructing every section's subtree, text
/// fields and all, each time the gap shifts.
final Map<String, PropertiesReorderSlot> _slots = {};
PropertiesReorderSlot _slotFor(String id, int index) {
final slot = _slots.putIfAbsent(id, () => PropertiesReorderSlot(index));
slot.index = index;
return slot;
}
@override
Widget build(BuildContext context) {
final entries = widget.entries;
final onReorder = widget.onReorder;
// Pinned sections are rendered OUTSIDE the reorderable list, not held at
// index 0 inside it.
//
// Inside, the list owns them: it shifts them out of the way as you drag
// past, and the only thing that can be done about it is to fix up the
// result in onReorder - so the section you were dragging visibly took the
// top slot and then snapped back one. A correction after the fact, and it
// looked like one.
//
// Out here theres nothing to correct. The pinned block cant move because
// it isnt in a list that moves things, nothing can be dropped above it
// because there is no slot above it, and the top of the reorderable list
// IS second place - so dragging to the top settles there instead of
// bouncing.
final top = entries.where((e) => e.pin == PropertiesPin.top).toList();
final bottom = entries.where((e) => e.pin == PropertiesPin.bottom).toList();
final movable = entries.where((e) => e.pin == null).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
// no scope, so no handle - a pinned section renders exactly as it
// would anywhere else.
for (final entry in top) entry.child,
if (movable.isNotEmpty) _reorderable(movable, top, bottom, onReorder),
for (final entry in bottom) entry.child,
],
);
}
Widget _reorderable(
List<PropertiesEntry> movable,
List<PropertiesEntry> top,
List<PropertiesEntry> bottom,
void Function(List<String> order) onReorder,
) {
// ReorderableList, from flutter/widgets - NOT material's
// ReorderableListView.
//
// The reordering machinery has allways lived in the widgets library;
// ReorderableListView is only material's wrapper round it. Using the
// wrapper meant a Material ancestor, and Material brings its own
// DefaultTextStyle and IconTheme - which sit UNDER garage's and quietly
// replaced the kit's typography and density derived sizes with flutter's
// defaults for everything inside the list. The widgets version wants
// WidgetsLocalizations and an Overlay, and GarageApp's WidgetsApp provides
// both.
return ReorderableList(
// it sits inside the pane's scroll view, so it takes its height from its
// children and doesnt scroll itself.
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: movable.length,
// the section, unchanged, while its being dragged - a section is a flat
// card on a flat pane and has nothing to lift off it.
proxyDecorator: (child, index, animation) => child,
itemBuilder: (context, i) {
final entry = movable[i];
return PropertiesReorderScope(
key: ValueKey(entry.id),
slot: _slotFor(entry.id, i),
draggable: entry.movable,
child: entry.child,
);
},
onReorder: (oldIndex, newIndex) {
// ReorderableList reports where the item would be INSERTED, which is
// one past itself when its moving down.
if (newIndex > oldIndex) newIndex -= 1;
final next = [...movable];
next.insert(newIndex, next.removeAt(oldIndex));
// the whole order, ends included: theyre still part of what the
// consumer stores, theyre just not part of the bit that moves.
onReorder([
for (final e in top) e.id,
for (final e in next) e.id,
for (final e in bottom) e.id,
]);
},
);
}
}
/// Tells a [PropertiesSection] which slot of a [PropertiesList] it is in.
///
/// The handle has to be drawn by the SECTION - it belongs in the header beside
/// the chevron - but only the list knows the index a drag has to quote. So the
/// list puts the slot in scope and the section picks it up. Any depth of
/// wrapper in between is fine, which is the point: call sites wrap their
/// sections in widgets of their own everywhere.
class PropertiesReorderScope extends InheritedWidget {
const PropertiesReorderScope({
super.key,
required this.slot,
required this.draggable,
required super.child,
});
final PropertiesReorderSlot slot;
final bool draggable;
static PropertiesReorderScope? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<PropertiesReorderScope>();
/// Deliberately NOT sensitive to the slot's index.
///
/// The index changes constantly while something is being dragged, and
/// notifying on it rebuilt every section in the list each time the gap
/// moved - four subtrees of rows and text fields, mid-animation, which is
/// what made the drag feel like it was catching. The slot is mutable so the
/// handle can read the current index when a drag actually starts, and the
/// only thing worth a rebuild is whether the section can be dragged at all.
@override
bool updateShouldNotify(PropertiesReorderScope old) =>
draggable != old.draggable || !identical(slot, old.slot);
}
/// Where a section currently sits, as a thing rather than a number.
///
/// A widget field would have to be replaced to change, and replacing it is
/// what triggers the rebuilds this exists to avoid. So the object stays and
/// the number inside it moves.
class PropertiesReorderSlot {
PropertiesReorderSlot(this.index);
int index;
}
/// The grab handle: starts a reorder on pointer down, reading the section's
/// position AT THAT MOMENT.
///
/// This is [ReorderableDragStartListener] with one difference, and it is the
/// whole point: that one takes its index as a constructor argument, so it has
/// to be rebuilt every time the index changes. This one reads it off the slot
/// when the pointer actually goes down, so nothing above it needs rebuilding
/// while a drag is in flight.
class _SectionDragHandle extends StatelessWidget {
const _SectionDragHandle({required this.slot, required this.child});
final PropertiesReorderSlot slot;
final Widget child;
@override
Widget build(BuildContext context) {
return Listener(
onPointerDown: (event) {
final list = SliverReorderableList.maybeOf(context);
list?.startItemDragReorder(
index: slot.index,
event: event,
recognizer: ImmediateMultiDragGestureRecognizer(debugOwner: this)
..gestureSettings = MediaQuery.maybeGestureSettingsOf(context),
);
},
child: child,
);
}
}