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 { 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? onReset; final ValueChanged? 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(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(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 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 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 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 order) onReorder; @override State createState() => _PropertiesListState(); } class _PropertiesListState extends State { /// 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 _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 movable, List top, List bottom, void Function(List 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(); /// 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, ); } }