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
This commit is contained in:
@@ -0,0 +1,940 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user