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
941 lines
37 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|