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:
ImBenji
2026-09-23 18:49:21 +01:00
co-authored by Claude Opus 5.5
commit b269201919
117 changed files with 26944 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
import "package:flutter/widgets.dart";
import "package:garage_ui/scrollbar.dart";
import "package:garage_ui/theme/garage_theme.dart";
/// The application shell for Garage UI apps.
///
/// This deliberately uses [WidgetsApp] rather than [MaterialApp]. Garage UI
/// owns the visual system while Flutter still supplies routing, overlays,
/// media queries, directionality, and localization plumbing.
class GarageApp extends StatelessWidget {
const GarageApp.router({
super.key,
required this.routerConfig,
required this.theme,
this.title = "",
this.builder,
this.locale,
this.localizationsDelegates,
this.supportedLocales = const <Locale>[Locale("en", "US")],
this.debugShowCheckedModeBanner = false,
});
final RouterConfig<Object> routerConfig;
final ThemeData theme;
final String title;
final TransitionBuilder? builder;
final Locale? locale;
final Iterable<LocalizationsDelegate<dynamic>>? localizationsDelegates;
final Iterable<Locale> supportedLocales;
final bool debugShowCheckedModeBanner;
@override
Widget build(BuildContext context) {
return WidgetsApp.router(
key: key,
title: title,
routerConfig: routerConfig,
color: theme.colorScheme.background,
locale: locale,
localizationsDelegates: localizationsDelegates,
supportedLocales: supportedLocales,
debugShowCheckedModeBanner: debugShowCheckedModeBanner,
// the Builder matters: without it `builder` is handed the context ABOVE
// this GarageTheme, so GarageTheme.of() asserts inside the very callback
// you were given for theming.
// ScrollConfiguration, not WidgetsApp's `scrollBehavior` - that
// parameter only exists on MaterialApp/CupertinoApp.
builder: (context, child) => ScrollConfiguration(
behavior: const GarageScrollBehavior(),
child: GarageTheme(
data: theme,
child: Builder(
builder: (themedContext) =>
builder?.call(themedContext, child) ??
child ??
const SizedBox.shrink(),
),
),
),
);
}
}
/// Scrolling without Material's overscroll glow.
///
/// The default ScrollBehavior hangs a GlowingOverscrollIndicator off every
/// scrollable on Android and Fuchsia - the grey arc that swells out of the
/// top or bottom edge when you drag past the end. Its a Material idiom, and
/// this kit isnt a Material app: it paints a big soft shape over flat chrome
/// and reads as a rendering fault rather than as feedback.
///
/// Physics and drag devices are untouched, so a list still throws and settles
/// the way the OS expects - it just doesnt glow at the ends. iOS and desktop
/// never had the glow in the first place, so this only levels the other
/// platforms up to what they already do.
///
/// The scrollbar is ours too. The base behaviour hands desktop a stock
/// [RawScrollbar] - 8px, square ends, a hardcoded grey that knows nothing
/// about the scheme - and thats the pale bar that used to sit beside every
/// nav list. [GarageScrollbar] replaces it here, once, for every scrollable
/// in the app. Touch platforms keep no bar at all, same as the default.
class GarageScrollBehavior extends ScrollBehavior {
const GarageScrollBehavior();
@override
Widget buildOverscrollIndicator(
BuildContext context,
Widget child,
ScrollableDetails details,
) => child;
@override
Widget buildScrollbar(
BuildContext context,
Widget child,
ScrollableDetails details,
) {
switch (getPlatform(context)) {
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
return GarageScrollbar(controller: details.controller, child: child);
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
return child;
}
}
}
+115
View File
@@ -0,0 +1,115 @@
import "dart:ui" as ui;
import "package:flutter/foundation.dart";
import "package:flutter/rendering.dart";
import "package:flutter/widgets.dart";
// Wraps the whole app in a RepaintBoundary we can snapshot on demand, so an
// eyedropper has something to read pixels out of on platforms with no system
// colour sampler (see platform/eyedropper.dart for the ones that do).
//
// This obviously only covers whats inside the flutter window - thats the
// tradeoff for it working everywhere. Mounted once, in main.dart, just under
// CustomCursorLayer so the fake cursor doesnt end up baked into the snapshot
// and get sampled by the very thing thats drawing it.
class AppFrameCapture extends StatelessWidget {
const AppFrameCapture({super.key, required this.child});
static final GlobalKey _boundaryKey = GlobalKey(
debugLabel: "app frame capture",
);
final Widget child;
@override
Widget build(BuildContext context) {
return RepaintBoundary(key: _boundaryKey, child: child);
}
/// Grabs the current frame. Returns null (and logs why) if theres nothing
/// to grab - no boundary mounted yet, or the raster came back empty.
static Future<AppFrameSnapshot?> capture() async {
final object = _boundaryKey.currentContext?.findRenderObject();
if (object is! RenderRepaintBoundary) {
debugPrint(
"AppFrameCapture.capture: no boundary mounted, is AppFrameCapture in "
"the tree?",
);
return null;
}
ui.Image? image;
try {
// pixelRatio 1 on purpose - it keeps image pixels and logical pixels the
// same thing, so colorAt can index straight off a pointer position with
// no dpr maths, and it keeps the byte buffer a quarter of the size on a
// retina display.
image = await object.toImage();
final width = image.width;
final height = image.height;
final bytes = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
if (bytes == null) {
debugPrint("AppFrameCapture.capture: toByteData returned null");
return null;
}
return AppFrameSnapshot._(
bytes.buffer.asUint8List(),
width,
height,
object.localToGlobal(Offset.zero),
);
} catch (error, stack) {
debugPrint("AppFrameCapture.capture failed: $error\n$stack");
return null;
} finally {
image?.dispose();
}
}
}
/// A frozen copy of the app window you can read single pixels out of.
class AppFrameSnapshot {
AppFrameSnapshot._(this._pixels, this.width, this.height, this.origin);
/// straight-from-bytes ctor, only so the sampling maths can be probed
/// without standing up a whole render tree.
@visibleForTesting
AppFrameSnapshot.fromRawRgba({
required Uint8List pixels,
required this.width,
required this.height,
this.origin = Offset.zero,
}) : _pixels = pixels;
final Uint8List _pixels;
final int width;
final int height;
/// where the captured boundary sits in global coordinates. normally zero,
/// but not worth assuming.
final Offset origin;
/// The colour at [globalPosition], or null if thats outside the captured
/// area or lands on a fully transparent pixel.
Color? colorAt(Offset globalPosition) {
final local = globalPosition - origin;
final x = local.dx.floor();
final y = local.dy.floor();
if (x < 0 || y < 0 || x >= width || y >= height) return null;
final i = (y * width + x) * 4;
final a = _pixels[i + 3];
if (a == 0) return null;
// rawRgba is premultiplied, so anything drawn over a translucent layer
// reads darker than it looks unless we undo that first.
int channel(int offset) {
final value = _pixels[i + offset];
if (a == 255) return value;
return (value * 255 / a).round().clamp(0, 255);
}
return Color.fromARGB(255, channel(0), channel(1), channel(2));
}
}
+235
View File
@@ -0,0 +1,235 @@
import "package:flutter/widgets.dart";
sealed class AppMenuItem {
const AppMenuItem();
}
class AppMenuGroup extends AppMenuItem {
const AppMenuGroup({
required this.label,
required this.children,
this.icon,
this.visible = true,
});
final String label;
final IconData? icon;
final List<AppMenuItem> children;
final bool visible;
List<AppMenuItem> get visibleChildren => children.where((item) {
return switch (item) {
AppMenuGroup group => group.visible,
AppMenuAction action => action.visible,
_ => true,
};
}).toList();
}
class AppMenuAction extends AppMenuItem {
const AppMenuAction({
required this.label,
this.icon,
this.trailingIcon,
this.onTap,
this.shortcut,
this.visible = true,
});
final String label;
final IconData? icon;
final IconData? trailingIcon;
final VoidCallback? onTap;
final SingleActivator? shortcut;
final bool visible;
bool get enabled => onTap != null;
}
class AppMenuCheck extends AppMenuItem {
const AppMenuCheck({
required this.label,
required this.checked,
this.checkedLabel,
this.onToggle,
this.shortcut,
});
final String label;
final String? checkedLabel;
final bool checked;
final VoidCallback? onToggle;
final SingleActivator? shortcut;
}
class AppMenuPlatformProvided extends AppMenuItem {
const AppMenuPlatformProvided(this.type);
final PlatformProvidedMenuItemType type;
}
class AppMenuSeparator extends AppMenuItem {
const AppMenuSeparator({this.visible = true});
final bool visible;
}
/// Converts the shared menu model to Flutter's native platform menu model.
class AppMenuNativeRenderer {
static List<PlatformMenuItem> build(
List<AppMenuGroup> menus, {
String appName = "Garage App",
}) {
final appItems = <PlatformMenuItem>[
for (final type in [
PlatformProvidedMenuItemType.about,
PlatformProvidedMenuItemType.servicesSubmenu,
PlatformProvidedMenuItemType.hide,
PlatformProvidedMenuItemType.hideOtherApplications,
PlatformProvidedMenuItemType.showAllApplications,
PlatformProvidedMenuItemType.quit,
])
if (PlatformProvidedMenuItem.hasMenu(type))
PlatformProvidedMenuItem(type: type),
];
return [
if (appItems.isNotEmpty) PlatformMenu(label: appName, menus: appItems),
for (final group in menus)
if (group.visible)
PlatformMenu(
label: group.label,
menus: _convertItems(group.visibleChildren),
),
];
}
static String signature(List<AppMenuGroup> menus) {
final buffer = StringBuffer();
for (final group in menus) {
if (!group.visible) continue;
_writeSignature(buffer, group);
buffer.write(";");
}
return buffer.toString();
}
static List<PlatformMenuItem> _convertItems(List<AppMenuItem> items) {
final groups = <List<AppMenuItem>>[<AppMenuItem>[]];
for (final item in items) {
if (item case AppMenuSeparator(visible: true)) {
groups.add(<AppMenuItem>[]);
} else if (item is! AppMenuSeparator) {
groups.last.add(item);
}
}
final result = <PlatformMenuItem>[];
for (var index = 0; index < groups.length; index++) {
final native = groups[index]
.map(_toNativeItem)
.whereType<PlatformMenuItem>();
final items = native.toList();
if (items.isEmpty) continue;
if (index == 0) {
result.addAll(items);
} else {
result.add(PlatformMenuItemGroup(members: items));
}
}
return result;
}
static PlatformMenuItem? _toNativeItem(AppMenuItem item) {
return switch (item) {
AppMenuGroup group when group.visible => PlatformMenu(
label: group.label,
menus: _convertItems(group.visibleChildren),
),
AppMenuAction action when action.visible => PlatformMenuItem(
label: action.label,
shortcut: action.shortcut,
onSelected: action.onTap,
),
AppMenuCheck check => PlatformMenuItem(
label: check.checked && check.checkedLabel != null
? check.checkedLabel!
: check.label,
shortcut: check.shortcut,
onSelected: check.onToggle,
),
AppMenuPlatformProvided provided
when PlatformProvidedMenuItem.hasMenu(provided.type) =>
PlatformProvidedMenuItem(type: provided.type),
_ => null,
};
}
static void _writeSignature(StringBuffer buffer, AppMenuItem item) {
switch (item) {
case AppMenuGroup group:
if (!group.visible) return;
buffer
..write("G(")
..write(group.label)
..write("|")
..write(group.icon?.codePoint ?? -1)
..write(")[");
for (final child in group.visibleChildren) {
_writeSignature(buffer, child);
buffer.write(",");
}
buffer.write("]");
case AppMenuAction action:
if (!action.visible) return;
buffer
..write("A(")
..write(action.label)
..write("|")
..write(action.icon?.codePoint ?? -1)
..write("|")
..write(action.enabled ? 1 : 0)
..write("|")
..write(_shortcutSignature(action.shortcut))
..write(")");
case AppMenuCheck check:
buffer
..write("C(")
..write(check.label)
..write("|")
..write(check.checkedLabel ?? "-")
..write("|")
..write(check.checked ? 1 : 0)
..write("|")
..write(check.onToggle != null ? 1 : 0)
..write("|")
..write(_shortcutSignature(check.shortcut))
..write(")");
case AppMenuPlatformProvided provided:
buffer
..write("P(")
..write(provided.type.name)
..write(")");
case AppMenuSeparator separator:
if (separator.visible) buffer.write("S");
}
}
// SingleActivator has no value-based toString (it's Diagnosticable, so the
// default one embeds the object's identity hash) - and menu_def.dart builds
// a fresh activator on every rebuild, so hashing the instance itself would
// make the signature change every frame and defeat the whole point of this
// gate. build it off the actual key/modifier fields instead, those are what
// we care about being stable.
static String _shortcutSignature(SingleActivator? shortcut) {
if (shortcut == null) return "-";
return [
shortcut.trigger.keyLabel,
shortcut.trigger.keyId.toRadixString(16),
shortcut.alt ? "a" : "",
shortcut.control ? "c" : "",
shortcut.meta ? "m" : "",
shortcut.shift ? "s" : "",
].join(":");
}
}
+67
View File
@@ -0,0 +1,67 @@
import "package:flutter/foundation.dart";
import "package:flutter/widgets.dart";
class AppMenuNotifier extends ChangeNotifier {
List<PlatformMenuItem> _menus = const [];
List<PlatformMenuItem> get menus => _menus;
void update(List<PlatformMenuItem> menus) {
_menus = menus;
notifyListeners();
}
}
/// Keeps one native platform menu host above an application's provider tree.
class PlatformMenuHost extends StatefulWidget {
const PlatformMenuHost({
super.key,
required this.notifier,
required this.child,
});
final AppMenuNotifier notifier;
final Widget child;
@override
State<PlatformMenuHost> createState() => _PlatformMenuHostState();
}
class _PlatformMenuHostState extends State<PlatformMenuHost> {
List<PlatformMenuItem> _menus = const [];
static bool get _supported =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.macOS;
@override
void initState() {
super.initState();
_menus = widget.notifier.menus;
widget.notifier.addListener(_onMenusChanged);
}
@override
void didUpdateWidget(PlatformMenuHost oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.notifier == oldWidget.notifier) return;
oldWidget.notifier.removeListener(_onMenusChanged);
widget.notifier.addListener(_onMenusChanged);
_menus = widget.notifier.menus;
}
@override
void dispose() {
widget.notifier.removeListener(_onMenusChanged);
super.dispose();
}
void _onMenusChanged() {
if (mounted) setState(() => _menus = widget.notifier.menus);
}
@override
Widget build(BuildContext context) {
if (!_supported) return widget.child;
return PlatformMenuBar(menus: _menus, child: widget.child);
}
}
File diff suppressed because it is too large Load Diff
+799
View File
@@ -0,0 +1,799 @@
import "dart:math" as math;
import "package:flutter/services.dart";
import "package:flutter/widgets.dart";
import "package:flutter_lucide/flutter_lucide.dart";
import "package:garage_ui/platform/eyedropper.dart";
import "package:garage_ui/app_frame_capture.dart";
import "package:garage_ui/button.dart";
import "package:garage_ui/extensions.dart";
import "package:garage_ui/theme/garage_theme.dart";
import "package:garage_ui/text_field.dart";
// hand rolled stand-in for shadcn's ColorInput. it isnt a 1:1 port of their
// picker but it does the same job the lines panel needs: a swatch you click to
// open an SV square + hue slider, streaming onChanging while you drag and
// firing onChanged when the popover closes.
enum PromptMode { popover, dialog }
// wraps a colour as HSV so dragging stays smooth (avoids the rounding wobble you
// get bouncing through rgb every frame).
class ColorDerivative {
ColorDerivative(this.hsv);
factory ColorDerivative.fromColor(Color color) =>
ColorDerivative(HSVColor.fromColor(color));
final HSVColor hsv;
Color toColor() => hsv.toColor();
ColorDerivative withHSV(HSVColor v) => ColorDerivative(v);
}
class ColorInput extends StatefulWidget {
const ColorInput({
super.key,
required this.value,
this.onChanged,
this.onChanging,
this.promptMode = PromptMode.popover,
this.popoverAlignment = Alignment.bottomLeft,
this.showAlpha = true,
});
final ColorDerivative value;
final ValueChanged<ColorDerivative>? onChanged;
final ValueChanged<ColorDerivative>? onChanging;
final PromptMode promptMode;
final Alignment popoverAlignment;
final bool showAlpha;
@override
State<ColorInput> createState() => _ColorInputState();
}
class _ColorInputState extends State<ColorInput> {
final LayerLink _link = LayerLink();
final TextEditingController _hexController = TextEditingController();
final TextEditingController _hueController = TextEditingController();
final TextEditingController _saturationController = TextEditingController();
final TextEditingController _valueController = TextEditingController();
final TextEditingController _redController = TextEditingController();
final TextEditingController _greenController = TextEditingController();
final TextEditingController _blueController = TextEditingController();
final TextEditingController _alphaController = TextEditingController();
OverlayEntry? _entry;
late HSVColor _hsv;
var _usesHsvChannels = true;
double _maxPopoverWidth = _pickerWidth;
double _maxPopoverHeight = double.infinity;
// eyedropper session state. _pickAnchor is the colour we snap back to if the
// pick gets cancelled, _pickEntry/_pickFrame only exist for the in-app
// fallback path (the native samplers own their own overlay).
OverlayEntry? _pickEntry;
AppFrameSnapshot? _pickFrame;
HSVColor? _pickAnchor;
var _isPicking = false;
// bumped every time a session starts or gets abandoned. the native sampler
// is owned by the OS and cant be dismissed from here, so cancelling one is
// really just "ignore whatever it eventually hands back".
var _pickSession = 0;
// These are deliberately kept in step with `_picker`. We need its bounds
// before building the overlay so that the follower can choose an edge that
// remains visible in a narrow inspector panel.
static const _pickerWidth = 200.0;
static const _pickerHeightWithoutAlpha = 420.0;
static const _pickerHeightWithAlpha = 460.0;
@override
void initState() {
super.initState();
_hsv = widget.value.hsv;
_syncHexValue();
_syncChannelValues();
}
@override
void didUpdateWidget(covariant ColorInput old) {
super.didUpdateWidget(old);
// only follow external changes while the popover is closed, otherwise our
// own drags fight the incoming value.
if (_entry == null) {
_hsv = widget.value.hsv;
_syncHexValue();
_syncChannelValues();
}
}
void _emitChanging(HSVColor v) {
setState(() => _hsv = v);
_syncHexValue();
_syncChannelValues();
widget.onChanging?.call(ColorDerivative(v));
_entry?.markNeedsBuild();
}
void _open() {
if (_entry != null) return;
// A colour input often lives inside a panel that has its own Overlay. A
// popup inserted there is clipped at the panel boundary, even though there
// is room elsewhere in the application window. Use the root overlay so
// the picker can safely escape its containing panel.
final overlay = Overlay.of(context, rootOverlay: true);
final theme = GarageTheme.of(context);
_syncHexValue();
final placement = _placementFor(overlay);
_maxPopoverWidth = placement.maxWidth;
_maxPopoverHeight = placement.maxHeight;
_entry = OverlayEntry(
builder: (ctx) {
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
onTap: _close,
),
),
CompositedTransformFollower(
link: _link,
showWhenUnlinked: false,
targetAnchor: placement.targetAnchor,
followerAnchor: placement.followerAnchor,
offset: placement.offset,
// The root Overlay can sit above GarageTheme in the widget tree.
// Preserve the caller's theme so the popup remains buildable.
child: GarageTheme(data: theme, child: _picker(theme)),
),
],
);
},
);
overlay.insert(_entry!);
}
_PopoverPlacement _placementFor(OverlayState overlay) {
final target = context.findRenderObject()! as RenderBox;
final overlayBox = overlay.context.findRenderObject()! as RenderBox;
final targetOffset = target.localToGlobal(
Offset.zero,
ancestor: overlayBox,
);
final overlaySize = overlayBox.size;
final targetSize = target.size;
const margin = 8.0;
const gap = 6.0;
final pickerHeight = widget.showAlpha
? _pickerHeightWithAlpha
: _pickerHeightWithoutAlpha;
final preferLeft = widget.popoverAlignment.x <= 0;
final roomOnRight = overlaySize.width - targetOffset.dx;
final roomOnLeft = targetOffset.dx + targetSize.width;
final openToRight = preferLeft
? roomOnRight >= _pickerWidth + margin || roomOnRight >= roomOnLeft
: roomOnRight > roomOnLeft;
final preferBelow = widget.popoverAlignment.y >= 0;
final roomBelow = overlaySize.height - targetOffset.dy - targetSize.height;
final roomAbove = targetOffset.dy;
final openBelow = preferBelow
? roomBelow >= pickerHeight + margin || roomBelow >= roomAbove
: roomBelow > roomAbove;
final availableHeight = (openBelow ? roomBelow : roomAbove) - gap - margin;
final availableWidth = (openToRight ? roomOnRight : roomOnLeft) - margin;
return _PopoverPlacement(
targetAnchor: Alignment(openToRight ? -1 : 1, openBelow ? 1 : -1),
followerAnchor: Alignment(openToRight ? -1 : 1, openBelow ? -1 : 1),
offset: Offset(0, openBelow ? gap : -gap),
maxWidth: math.max(0.0, availableWidth),
maxHeight: math.max(0.0, availableHeight),
);
}
void _close() {
// shouldnt normally happen (the pick surface sits above the popovers own
// dismiss barrier) but leaving a full screen overlay behind would be nasty
if (_pickEntry != null) _endInAppPick(commit: false);
_entry?.remove();
_entry = null;
widget.onChanged?.call(ColorDerivative(_hsv));
}
@override
void dispose() {
_entry?.remove();
_entry = null;
_pickEntry?.remove();
_pickEntry = null;
_pickFrame = null;
_hexController.dispose();
_hueController.dispose();
_saturationController.dispose();
_valueController.dispose();
_redController.dispose();
_greenController.dispose();
_blueController.dispose();
_alphaController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
return CompositedTransformTarget(
link: _link,
child: Semantics(
container: true,
button: true,
label: "Colour",
onTap: _open,
child: GestureDetector(
onTap: _open,
excludeFromSemantics: true,
child: Container(
width: 40 * theme.scaling,
height: theme.density.controlHeight,
padding: EdgeInsets.all(theme.density.controlBorderWidth),
decoration: BoxDecoration(
color: theme.colorScheme.secondary,
borderRadius: BorderRadius.circular(theme.radiusMd),
),
child: DecoratedBox(
decoration: BoxDecoration(
color: _hsv.toColor(),
borderRadius: BorderRadius.circular(
math.max(
0,
theme.radiusMd - theme.density.controlBorderWidth,
),
),
),
),
),
),
),
);
}
Widget _picker(ThemeData theme) {
final density = theme.density;
return Container(
width: math.min(_pickerWidth, _maxPopoverWidth),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.popover,
borderRadius: theme.borderRadiusLg,
border: Border.all(
color: theme.colorScheme.popoverBorder,
width: theme.scaling,
),
boxShadow: const [
BoxShadow(
color: Color(0x33000000),
blurRadius: 12,
offset: Offset(0, 4),
),
],
),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: _maxPopoverHeight),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_wheelAndValueSlider(),
SizedBox(height: density.containerGap),
ButtonGroup.horizontal(
children: [
Expanded(
child: _usesHsvChannels
? Button.secondary(
onPressed: () =>
_updatePicker(() => _usesHsvChannels = false),
child: const Text("RGB"),
)
: Button.primary(
onPressed: () =>
_updatePicker(() => _usesHsvChannels = true),
child: const Text("RGB"),
),
),
Expanded(
child: _usesHsvChannels
? Button.primary(
onPressed: () =>
_updatePicker(() => _usesHsvChannels = false),
child: const Text("HSV"),
)
: Button.secondary(
onPressed: () =>
_updatePicker(() => _usesHsvChannels = true),
child: const Text("HSV"),
),
),
],
),
SizedBox(height: density.containerGap),
ButtonGroup.vertical(
children: [
if (_usesHsvChannels) ...[
_channelRow(
"Hue",
_hueController,
(value) => _hsv.withHue(value * 360),
),
_channelRow(
"Saturation",
_saturationController,
_hsv.withSaturation,
),
_channelRow("Value", _valueController, _hsv.withValue),
] else ...[
_rgbChannelRow("Red", _redController, 16),
_rgbChannelRow("Green", _greenController, 8),
_rgbChannelRow("Blue", _blueController, 0),
],
if (widget.showAlpha)
_channelRow("Alpha", _alphaController, _hsv.withAlpha),
],
),
SizedBox(height: density.containerGap),
_hexRow(),
],
),
),
),
);
}
Widget _wheelAndValueSlider() {
return SizedBox(
height: 160,
child: Row(
children: [
Expanded(child: _hueSaturationWheel()),
const SizedBox(width: 8),
SizedBox(width: 16, height: 160, child: _valueSlider()),
],
),
);
}
Widget _hueSaturationWheel() {
return LayoutBuilder(
builder: (context, constraints) {
final side = math.min(constraints.maxWidth, constraints.maxHeight);
void handle(Offset local) {
final center = Offset(side / 2, side / 2);
final delta = local - center;
final saturation = (delta.distance / (side / 2)).clamp(0.0, 1.0);
final hue =
(math.atan2(delta.dy, delta.dx) * 180 / math.pi + 360) % 360;
_emitChanging(_hsv.withHue(hue).withSaturation(saturation));
}
final angle = _hsv.hue * math.pi / 180;
final thumbOffset = Offset(
side / 2 + math.cos(angle) * (side / 2) * _hsv.saturation,
side / 2 + math.sin(angle) * (side / 2) * _hsv.saturation,
);
return Center(
child: SizedBox(
width: side,
height: side,
child: GestureDetector(
// colour wheel drag surface, not a control - keep it out of the
// semantics tree so it doesn't read as a bogus scrollable
excludeFromSemantics: true,
onPanDown: (details) => handle(details.localPosition),
onPanUpdate: (details) => handle(details.localPosition),
child: CustomPaint(
painter: _ColorWheelPainter(),
child: Stack(
children: [
Positioned(
left: thumbOffset.dx - 8,
top: thumbOffset.dy - 8,
child: _thumb(),
),
],
),
),
),
),
);
},
);
}
Widget _valueSlider() {
return LayoutBuilder(
builder: (context, constraints) {
const handleHeight = 10.0;
final handleTop =
((1 - _hsv.value) * (constraints.maxHeight - handleHeight)).clamp(
0.0,
constraints.maxHeight - handleHeight,
);
void handle(Offset local) => _emitChanging(
_hsv.withValue(
(1 - local.dy / constraints.maxHeight).clamp(0.0, 1.0),
),
);
return GestureDetector(
// colour wheel drag surface, not a control - keep it out of the
// semantics tree so it doesn't read as a bogus scrollable
excludeFromSemantics: true,
onPanDown: (details) => handle(details.localPosition),
onPanUpdate: (details) => handle(details.localPosition),
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: GarageTheme.of(
context,
).colorScheme.controlBorder,
),
borderRadius: BorderRadius.circular(4),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
_hsv.withValue(1).toColor(),
const Color(0xFF000000),
],
),
),
),
),
),
Positioned(
left: -2,
right: -2,
top: handleTop,
height: handleHeight,
child: Container(
decoration: BoxDecoration(
color: _hsv.toColor(),
border: Border.all(color: const Color(0xFFFFFFFF)),
borderRadius: BorderRadius.circular(4),
),
),
),
],
),
);
},
);
}
Widget _channelRow(
String label,
TextEditingController controller,
HSVColor Function(double value) update,
) {
return TextField(
controller: controller,
textAlign: TextAlign.end,
variant: TextFieldVariant.secondary,
keyboardType: TextInputType.number,
features: [
InputFeature.leading(Text(label)),
InputFeature.fillIndicator(min: 0, max: 1),
InputFeature.scrub(
sensitivity: 0.01,
decimals: 3,
onUpdate: (text) => _setChannel(text, update),
),
],
onChanged: (text) => _setChannel(text, update),
);
}
Widget _rgbChannelRow(
String label,
TextEditingController controller,
int shift,
) {
return TextField(
controller: controller,
textAlign: TextAlign.end,
variant: TextFieldVariant.secondary,
keyboardType: TextInputType.number,
features: [
InputFeature.leading(Text(label)),
InputFeature.fillIndicator(min: 0, max: 1),
InputFeature.scrub(
sensitivity: 0.01,
decimals: 3,
onUpdate: (text) => _setRgbChannel(text, shift),
),
],
onChanged: (text) => _setRgbChannel(text, shift),
);
}
void _updatePicker(VoidCallback update) {
setState(update);
_entry?.markNeedsBuild();
}
Widget _hexRow() {
final density = GarageTheme.of(context).density;
return Row(
children: [
const Text("Hex").muted(),
SizedBox(width: density.controlGap * 2),
Expanded(
child: TextField(
controller: _hexController,
textAlign: TextAlign.center,
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp("[0-9a-fA-F#]")),
],
onChanged: _setHex,
),
),
SizedBox(width: density.controlGap),
// same toggle-to-cancel behaviour the object eyedropper in
// object_field.dart uses - pressing it again while armed backs out
_isPicking
? IconButton.primary(
icon: Icon(LucideIcons.pipette).iconSmall,
onPressed: _cancelEyedropper,
)
: IconButton.secondary(
icon: Icon(LucideIcons.pipette).iconSmall,
onPressed: _startEyedropper,
),
],
);
}
// ---- eyedropper -------------------------------------------------------
Future<void> _startEyedropper() async {
if (_isPicking) return;
final session = ++_pickSession;
_pickAnchor = _hsv;
_updatePicker(() => _isPicking = true);
// macOS / chromium can sample the whole desktop. Their samplers draw
// their own magnified loupe and only report the pixel you settle on, so
// theres nothing to stream back as a preview on this path.
if (await nativeEyedropperAvailable()) {
final picked = await pickNativeScreenColor();
if (!mounted || session != _pickSession) return;
_updatePicker(() => _isPicking = false);
_pickAnchor = null;
if (picked != null) _emitChanging(HSVColor.fromColor(picked));
return;
}
if (!mounted || session != _pickSession) return;
await _startInAppPick();
}
// fallback for platforms with no system sampler: freeze a copy of our own
// window and read pixels out of that while the cursor moves over it.
Future<void> _startInAppPick() async {
final frame = await AppFrameCapture.capture();
if (!mounted) return;
if (frame == null) {
// capture() already said why
_updatePicker(() => _isPicking = false);
_pickAnchor = null;
return;
}
_pickFrame = frame;
final theme = GarageTheme.of(context);
_pickEntry = OverlayEntry(
builder: (ctx) => GarageTheme(data: theme, child: _pickSurface()),
);
Overlay.of(context, rootOverlay: true).insert(_pickEntry!);
_updatePicker(() {});
}
Widget _pickSurface() {
return Positioned.fill(
child: Focus(
autofocus: true,
onKeyEvent: (node, event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
if (event.logicalKey != LogicalKeyboardKey.escape) {
return KeyEventResult.ignored;
}
_cancelEyedropper();
return KeyEventResult.handled;
},
child: MouseRegion(
cursor: SystemMouseCursors.precise,
opaque: true,
onHover: (event) => _previewPickAt(event.position),
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (event) {
_previewPickAt(event.position);
_endInAppPick(commit: true);
},
child: const SizedBox.expand(),
),
),
),
);
}
void _previewPickAt(Offset globalPosition) {
final color = _pickFrame?.colorAt(globalPosition);
if (color == null) return;
_emitChanging(HSVColor.fromColor(color));
}
void _cancelEyedropper() {
if (_pickEntry != null) {
_endInAppPick(commit: false);
return;
}
// the native sampler stays on screen until the user deals with it (its the
// OSs window, not ours) - all we can do is disown the session so whatever
// it eventually reports gets dropped on the floor.
_pickSession++;
_pickAnchor = null;
_updatePicker(() => _isPicking = false);
}
void _endInAppPick({required bool commit}) {
_pickSession++;
_pickEntry?.remove();
_pickEntry = null;
_pickFrame = null;
final anchor = _pickAnchor;
_pickAnchor = null;
if (!mounted) return;
if (!commit && anchor != null) _emitChanging(anchor);
_updatePicker(() => _isPicking = false);
}
void _setHex(String value) {
final hex = value.replaceAll("#", "");
if (hex.length != 6 && hex.length != 8) return;
final parsed = int.tryParse(hex, radix: 16);
if (parsed == null) return;
final color = hex.length == 6
? Color(0xFF000000 | parsed)
: Color(((parsed & 0xFF) << 24) | (parsed >> 8));
_emitChanging(HSVColor.fromColor(color));
}
void _syncHexValue() {
final argb = _hsv.toColor().toARGB32();
String component(int shift) =>
((argb >> shift) & 0xFF).toRadixString(16).padLeft(2, "0");
final display =
"#${component(16)}${component(8)}${component(0)}${widget.showAlpha ? component(24) : ""}"
.toUpperCase();
if (_hexController.text != display) _hexController.text = display;
}
void _syncChannelValues() {
void sync(TextEditingController controller, double value) {
final text = value.toStringAsFixed(3);
if (controller.text != text) controller.text = text;
}
sync(_hueController, _hsv.hue / 360);
sync(_saturationController, _hsv.saturation);
sync(_valueController, _hsv.value);
final argb = _hsv.toColor().toARGB32();
sync(_redController, ((argb >> 16) & 0xFF) / 255);
sync(_greenController, ((argb >> 8) & 0xFF) / 255);
sync(_blueController, (argb & 0xFF) / 255);
sync(_alphaController, _hsv.alpha);
}
void _setChannel(String text, HSVColor Function(double value) update) {
final value = double.tryParse(text);
if (value != null) _emitChanging(update(value.clamp(0.0, 1.0)));
}
void _setRgbChannel(String text, int shift) {
final value = double.tryParse(text);
if (value == null) return;
final argb = _hsv.toColor().toARGB32();
final channel = (value.clamp(0.0, 1.0) * 255).round();
final nextArgb = (argb & ~(0xFF << shift)) | (channel << shift);
_emitChanging(HSVColor.fromColor(Color(nextArgb)));
}
Widget _thumb() {
return Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFFFFFFFF), width: 2),
boxShadow: const [BoxShadow(color: Color(0x66000000), blurRadius: 2)],
),
);
}
}
class _ColorWheelPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final center = size.center(Offset.zero);
final radius = size.shortestSide / 2;
final bounds = Rect.fromCircle(center: center, radius: radius);
canvas.save();
canvas.clipPath(Path()..addOval(bounds));
canvas.drawRect(
bounds,
Paint()
..shader = SweepGradient(
colors: const [
Color(0xFFFF0000),
Color(0xFFFFFF00),
Color(0xFF00FF00),
Color(0xFF00FFFF),
Color(0xFF0000FF),
Color(0xFFFF00FF),
Color(0xFFFF0000),
],
).createShader(bounds),
);
canvas.drawCircle(
center,
radius,
Paint()
..shader = RadialGradient(
colors: const [Color(0xFFFFFFFF), Color(0x00FFFFFF)],
).createShader(bounds),
);
canvas.restore();
canvas.drawCircle(
center,
radius,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = const Color(0x66000000),
);
}
@override
bool shouldRepaint(covariant _ColorWheelPainter oldDelegate) => false;
}
class _PopoverPlacement {
const _PopoverPlacement({
required this.targetAnchor,
required this.followerAnchor,
required this.offset,
required this.maxWidth,
required this.maxHeight,
});
final Alignment targetAnchor;
final Alignment followerAnchor;
final Offset offset;
final double maxWidth;
final double maxHeight;
}
+116
View File
@@ -0,0 +1,116 @@
import "package:flutter/widgets.dart";
import "menu.dart";
const double _kContextMenuGap = 8.0;
const double _kContextMenuScreenPadding = 8.0;
const double _kContextMenuMinWidth = 192.0;
// shows a right-click style context menu at [globalPosition]. used to lean on
// shadcn's OverlayManager.showMenu; now its a plain flutter Overlay entry with
// a transparent tap-catcher to dismiss.
void showContextMenu({
required BuildContext context,
required Offset globalPosition,
required List<MenuItem> items,
required VoidCallback onDismissed,
}) {
final overlay = Overlay.of(context, rootOverlay: true);
final overlayBox = overlay.context.findRenderObject() as RenderBox?;
final overlayPosition =
overlayBox?.globalToLocal(globalPosition) ?? globalPosition;
late OverlayEntry entry;
var closed = false;
void close() {
if (closed) return;
closed = true;
entry.remove();
onDismissed();
}
entry = OverlayEntry(
builder: (ctx) {
return Stack(
children: [
// tap anywhere outside to dismiss.
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
onTap: close,
child: const SizedBox.expand(),
),
),
CustomSingleChildLayout(
delegate: _ContextMenuLayoutDelegate(position: overlayPosition),
child: MenuGroup(
direction: Axis.vertical,
subMenuOffset: const Offset(8, -4),
onDismissed: close,
builder: (ctx, children) => MenuPopup(children: children),
children: items,
),
),
],
);
},
);
overlay.insert(entry);
}
class _ContextMenuLayoutDelegate extends SingleChildLayoutDelegate {
const _ContextMenuLayoutDelegate({required this.position});
final Offset position;
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
final maxWidth = (constraints.maxWidth - _kContextMenuScreenPadding * 2)
.clamp(0.0, double.infinity);
final maxHeight = (constraints.maxHeight - _kContextMenuScreenPadding * 2)
.clamp(0.0, double.infinity);
return BoxConstraints(
minWidth: _kContextMenuMinWidth.clamp(0.0, maxWidth),
maxWidth: maxWidth,
maxHeight: maxHeight,
);
}
@override
Offset getPositionForChild(Size size, Size childSize) {
final minLeft = _kContextMenuScreenPadding;
final minTop = _kContextMenuScreenPadding;
final maxLeft = size.width - _kContextMenuScreenPadding - childSize.width;
final maxTop = size.height - _kContextMenuScreenPadding - childSize.height;
final preferredRight = position.dx + _kContextMenuGap;
final preferredLeft = position.dx - _kContextMenuGap - childSize.width;
final rightFits =
preferredRight + childSize.width <=
size.width - _kContextMenuScreenPadding;
final leftFits = preferredLeft >= _kContextMenuScreenPadding;
final opensRight = rightFits || !leftFits;
final rawLeft = opensRight ? preferredRight : preferredLeft;
final preferredBelow = position.dy;
final preferredAbove = position.dy - childSize.height;
final belowFits =
preferredBelow + childSize.height <=
size.height - _kContextMenuScreenPadding;
final aboveFits = preferredAbove >= _kContextMenuScreenPadding;
final opensBelow = belowFits || !aboveFits;
final rawTop = opensBelow ? preferredBelow : preferredAbove;
return Offset(
rawLeft.clamp(minLeft, maxLeft.clamp(minLeft, double.infinity)),
rawTop.clamp(minTop, maxTop.clamp(minTop, double.infinity)),
);
}
@override
bool shouldRelayout(_ContextMenuLayoutDelegate oldDelegate) {
return position != oldDelegate.position;
}
}
File diff suppressed because it is too large Load Diff
+308
View File
@@ -0,0 +1,308 @@
import "package:flutter/widgets.dart";
import "panel.dart";
import "theme/garage_theme.dart";
/// The reusable desktop editor frame.
///
/// Applications provide the content for each slot. The frame owns the
/// geometry and visual separation between the header, workspace, docks, and
/// footer, so an editor can keep its app-specific controls outside GarageUI.
class EditorShell extends StatelessWidget {
const EditorShell({
super.key,
required this.center,
this.header,
this.footer,
this.left,
this.right,
this.leftWidth = 0,
this.rightWidth = 0,
this.gap = 1,
this.backgroundColor,
});
final Widget center;
final Widget? header;
final Widget? footer;
final Widget? left;
final Widget? right;
final double leftWidth;
final double rightWidth;
final double gap;
final Color? backgroundColor;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final divider = theme.colorScheme.divider;
Widget dock(Widget child, double width) =>
SizedBox(width: width, child: child);
Widget separator() => ColoredBox(
color: divider,
child: SizedBox(width: gap),
);
final workspace = Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (left != null && leftWidth > 0) ...[
dock(left!, leftWidth),
separator(),
],
Expanded(child: center),
if (right != null && rightWidth > 0) ...[
separator(),
dock(right!, rightWidth),
],
],
);
return ColoredBox(
color: backgroundColor ?? theme.colorScheme.background,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (header != null) header!,
Expanded(child: workspace),
if (footer != null) footer!,
],
),
);
}
}
/// Shared surface treatment for an editor's top and bottom chrome.
class ChromeBar extends StatelessWidget implements PreferredSizeWidget {
const ChromeBar({
super.key,
required this.child,
this.height,
this.padding = EdgeInsets.zero,
});
final Widget child;
/// Leave null to take [Density.chromeBarHeight], which is what keeps the
/// bar in step with the controls inside it. Was a hardcoded 30 that didnt
/// move with the tier at all, so at product density the bar came out
/// shorter than its own buttons.
final double? height;
final EdgeInsetsGeometry padding;
/// Without a BuildContext theres no density to ask, so this can only report
/// an explicit height. Nothing reads it today; the fallback matches the way
/// surface.dart's dividers handle the same problem.
@override
Size get preferredSize => Size.fromHeight(height ?? 0);
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
return SizedBox(
height: height ?? theme.density.chromeBarHeight,
child: DecoratedBox(
decoration: BoxDecoration(color: theme.colorScheme.chrome),
child: Padding(padding: padding, child: child),
),
);
}
}
/// A dock surface with optional package-owned header and footer slots.
class DockPanel extends StatelessWidget {
const DockPanel({
super.key,
required this.child,
this.header,
this.footer,
this.padding = EdgeInsets.zero,
});
final Widget child;
final Widget? header;
final Widget? footer;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
return Panel(
borderRadius: 0,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (header != null) header!,
Expanded(
child: Padding(padding: padding, child: child),
),
if (footer != null) footer!,
],
),
);
}
}
/// Garage editor layout: a main surface and two vertically stacked
/// sidebar surfaces separated by a draggable divider.
///
/// The shell owns the complete editor frame: header, workspace, footer, sizing,
/// hover highlighting, and the divider interaction. The application supplies
/// the header/footer content and the canvas, explorer, and properties widgets.
class GarageShell extends StatefulWidget {
const GarageShell({
super.key,
required this.main,
required this.sidebarTop,
required this.sidebarBottom,
this.header,
this.footer,
this.initialSidebarWidth = 320,
this.minPanelWidth = 100,
this.gap,
this.handleHeight = 200,
});
final Widget main;
final Widget sidebarTop;
final Widget sidebarBottom;
final Widget? header;
final Widget? footer;
final double initialSidebarWidth;
final double minPanelWidth;
/// Null uses the active theme's [ThemeData.panelGap], matching the original
/// Arcs & Angles Blender shell exactly.
final double? gap;
final double handleHeight;
@override
State<GarageShell> createState() => _GarageShellState();
}
class _GarageShellState extends State<GarageShell> {
late double _sidebarWidth = widget.initialSidebarWidth;
int? _hoveredPanel;
bool _dragging = false;
void _resizeSidebar(
DragUpdateDetails details,
double availableWidth,
double gap,
) {
final overhead = gap * 3;
final maxSidebarWidth = (availableWidth - overhead - widget.minPanelWidth)
.clamp(widget.minPanelWidth, double.infinity);
setState(() {
_sidebarWidth = (_sidebarWidth - details.delta.dx).clamp(
widget.minPanelWidth,
maxSidebarWidth,
);
});
}
Widget _panel(int id, Widget child) {
return MouseRegion(
onEnter: (_) {
if (_hoveredPanel != id) setState(() => _hoveredPanel = id);
},
onExit: (_) {
if (_hoveredPanel == id) setState(() => _hoveredPanel = null);
},
child: Panel(
active: _hoveredPanel == id,
clipBehavior: Clip.hardEdge,
child: child,
),
);
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scheme = theme.colorScheme;
final gap = widget.gap ?? theme.panelGap;
final workspace = ColoredBox(
color: scheme.chrome,
child: LayoutBuilder(
builder: (context, constraints) {
final sidebarWidth = _sidebarWidth;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Padding(
padding: EdgeInsets.only(left: gap),
child: _panel(0, widget.main),
),
),
SizedBox(
width: gap,
child: Align(
alignment: Alignment.center,
child: SizedBox(
height: widget.handleHeight,
child: MouseRegion(
cursor: SystemMouseCursors.resizeColumn,
child: GestureDetector(
// pane splitter, not a control - keep it out of the
// semantics tree so it doesn't read as a bogus scrollable
excludeFromSemantics: true,
behavior: HitTestBehavior.translucent,
onHorizontalDragStart: (_) =>
setState(() => _dragging = true),
onHorizontalDragUpdate: (details) =>
_resizeSidebar(details, constraints.maxWidth, gap),
onHorizontalDragEnd: (_) =>
setState(() => _dragging = false),
onHorizontalDragCancel: () =>
setState(() => _dragging = false),
child: SizedBox(
width: gap,
child: Center(
child: _dragging
? Container(
width: 1.5,
color: scheme.mutedForeground,
)
: null,
),
),
),
),
),
),
),
Padding(
padding: EdgeInsets.only(right: gap),
child: SizedBox(
width: sidebarWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: _panel(1, widget.sidebarTop)),
SizedBox(height: gap),
Expanded(child: _panel(2, widget.sidebarBottom)),
],
),
),
),
],
);
},
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.header != null) widget.header!,
Expanded(child: workspace),
if (widget.footer != null) widget.footer!,
],
);
}
}
+145
View File
@@ -0,0 +1,145 @@
import "package:flutter/widgets.dart";
// our own theme now - we only need it for the icon size theme (iconTheme.small etc).
import "package:garage_ui/theme/garage_theme.dart";
// Hand rolled replacements for the shadcn Widget/Icon extension methods.
// These get used 350+ times across the app so behaviour has to match the
// original exactly — same SizedBox/Padding/Center/Expanded wrappers, same
// icon sizes pulled from the theme.
/// Layout helpers that shadcn hangs off every Widget.
extension WidgetExtension on Widget {
/// Wraps this widget in a [SizedBox]. If it's already a SizedBox we merge
/// the dims instead of double wrapping (matches shadcn).
Widget sized({double? width, double? height, double? size}) {
if (this is SizedBox) {
return SizedBox(
width: width ?? size ?? (this as SizedBox).width,
height: height ?? size ?? (this as SizedBox).height,
child: (this as SizedBox).child,
);
}
return SizedBox(width: width ?? size, height: height ?? size, child: this);
}
/// Wraps this widget in [Padding]. You can pass individual edges, the
/// combined horizontal/vertical, a uniform `all`, or a raw EdgeInsets via
/// `padding` (which wins over everything else).
Widget withPadding({
double? top,
double? bottom,
double? left,
double? right,
double? horizontal,
double? vertical,
double? all,
EdgeInsetsGeometry? padding,
}) {
assert(() {
if (all != null) {
if (top != null ||
bottom != null ||
left != null ||
right != null ||
horizontal != null ||
vertical != null) {
throw FlutterError(
"All padding properties cannot be used with other padding properties.",
);
}
} else if (horizontal != null) {
if (left != null || right != null) {
throw FlutterError(
"Horizontal padding cannot be used with left or right padding.",
);
}
} else if (vertical != null) {
if (top != null || bottom != null) {
throw FlutterError(
"Vertical padding cannot be used with top or bottom padding.",
);
}
}
return true;
}());
var edgeInsets = EdgeInsets.only(
top: top ?? vertical ?? all ?? 0,
bottom: bottom ?? vertical ?? all ?? 0,
left: left ?? horizontal ?? all ?? 0,
right: right ?? horizontal ?? all ?? 0,
);
return Padding(padding: padding ?? edgeInsets, child: this);
}
/// Centers the widget in its parent.
Widget center({Key? key}) {
return Center(key: key, child: this);
}
/// Makes this widget [Expanded] inside a Row/Column.
Widget expanded({int flex = 1}) {
return Expanded(flex: flex, child: this);
}
}
/// Icon size helpers. shadcn returns a WrappedIcon that reads the size off the
/// theme (theme.iconTheme.small etc) so it respects AdaptiveScaling — we do the
/// exact same thing rather than hardcoding, otherwise scaled builds drift.
extension IconExtension on Widget {
/// small icon — 16px at scale 1.
Widget get iconSmall {
return _GarageWrappedIcon(pick: (t) => t.iconTheme.small, child: this);
}
/// medium icon — 20px at scale 1.
Widget get iconMedium {
return _GarageWrappedIcon(pick: (t) => t.iconTheme.medium, child: this);
}
/// large icon — 24px at scale 1.
Widget get iconLarge {
return _GarageWrappedIcon(pick: (t) => t.iconTheme.large, child: this);
}
}
typedef _IconThemePicker = IconThemeData Function(ThemeData theme);
// mirrors shadcn's WrappedIcon — but merges the SIZE ONLY.
//
// The tier's IconThemeData carries the global scheme foreground alongside its
// size, and merging that whole thing overrode whatever IconTheme already
// enclosed the icon. Inside a Button that meant the button's own variant
// colour lost to the global one: a primary button hands its leading icon
// primaryForeground, and `.iconSmall` put scheme.foreground back over the top.
// On a dark scheme both of those are near-white while the primary FILL is too,
// so the icon painted white on white and simply vanished.
//
// Size is the only thing these helpers are for. Colour is the enclosing
// theme's business - a button's, or the app's for an icon standing on its own,
// which is the same value the tier was supplying anyway.
class _GarageWrappedIcon extends StatelessWidget {
final _IconThemePicker pick;
final Widget child;
const _GarageWrappedIcon({required this.pick, required this.child});
@override
Widget build(BuildContext context) {
double? size;
try {
size = pick(GarageTheme.of(context)).size;
} catch (e, st) {
// shouldnt happen (there's always a Theme above us) but if it does we
// still want a visible icon rather than a crash. log it so it's debuggable.
debugPrint("ana iconTheme lookup failed: $e");
debugPrintStack(stackTrace: st);
size = 16;
}
return IconTheme.merge(
data: IconThemeData(size: size),
child: child,
);
}
}
+92
View File
@@ -0,0 +1,92 @@
import "package:flutter/widgets.dart";
import "package:garage_ui/theme/garage_theme.dart";
/// How long a field takes to admit theres something wrong with it, and how
/// long the row takes to make room for saying so. One number for both: the
/// outline and the line under it are one event and shouldnt arrive at
/// different times.
const Duration kFieldErrorDuration = Duration(milliseconds: 180);
const Curve kFieldErrorCurve = Curves.easeInOutCubic;
/// "This one was rejected", published over a control rather than set on it.
///
/// A row knows the value is wrong. What the row is HOLDING could be anything -
/// a text box, a select, a group of them - so the row cant reach in and colour
/// it. It says so here instead, and every control that draws itself a border
/// reads this on the way past.
///
/// Only the border moves. A field whose fill goes red reads as a state the
/// thing is permanently in; a red outline reads as a correction, which is what
/// this is - and it goes back to normal the moment the value does.
class FieldErrorScope extends InheritedWidget {
const FieldErrorScope({
super.key,
required this.invalid,
required super.child,
});
final bool invalid;
static bool of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<FieldErrorScope>()?.invalid ??
false;
@override
bool updateShouldNotify(FieldErrorScope old) => old.invalid != invalid;
}
/// The destructive outline, faded in OVER whatever border is allready there.
///
/// Over rather than instead of, deliberately. A control's fill changes the
/// instant you hover it - button.dart says so in as many words, and a
/// TextField matches it - so rebuilding the decoration with an animated
/// colour in it would have dragged the fill into the transition too and made
/// every hover ease. This sits on top and is the only thing that moves.
///
/// It also means a control with no border at all - a ghost select, say - gets
/// one here for nothing, which is what "the border goes red" needs in order
/// to mean anything.
class FieldErrorOutline extends StatelessWidget {
const FieldErrorOutline({
super.key,
required this.invalid,
required this.borderRadius,
required this.child,
});
final bool invalid;
final BorderRadiusGeometry borderRadius;
final Widget child;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
return Stack(
children: [
child,
// Positioned.fill, so it takes the child's size rather than joining in
// deciding what that size is.
Positioned.fill(
child: IgnorePointer(
child: AnimatedOpacity(
opacity: invalid ? 1.0 : 0.0,
duration: kFieldErrorDuration,
curve: kFieldErrorCurve,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: borderRadius,
border: Border.all(
color: theme.colorScheme.destructive,
width: theme.density.controlBorderWidth,
),
),
),
),
),
),
],
);
}
}
+52
View File
@@ -0,0 +1,52 @@
// Public Garage UI surface.
//
// Two influences, and theyre not the same kind of influence. The API surface
// follows shadcn_flutter - the variant names, the ColourScheme slots, the
// dot-constructor shape - because that vocabulary is good and the app was
// built against it. The way things actually render follows Blender: flat
// chrome, bordered panels, dense controls, no elevation.
//
// So this is NOT a pixel for pixel restyle of shadcn. It reads as a Garage
// app, not a shadcn one - the geometry, the density and the whole chrome
// layer are ours. If youre porting a shadcn snippet, expect the call to
// compile and the result to look different, on purpose.
//
// shadcn_flutter itself is long gone as a dependency. Import this, or the
// individual files.
export "app.dart";
export "button.dart";
export "select.dart";
export "text_field.dart";
export "input_mask.dart";
export "menu.dart";
export "surface.dart";
export "selection_controls.dart";
// overlay's own Popover/PopoverController subsystem was dropped (unused - the
// app's only PopoverController is menu.dart's, via menu_bar).
export "overlay.dart";
export "pane_overlay.dart";
export "toast.dart";
export "navigation.dart";
export "extensions.dart";
export "misc.dart";
export "color_input.dart";
export "date_input.dart";
export "panel.dart";
export "editor_chrome.dart";
export "context_menu.dart";
export "properties.dart";
export "semantics_scope.dart";
export "scroll_edge_fade.dart";
export "scrollbar.dart";
export "settings_list.dart";
export "field_error.dart";
export "tab_view.dart";
export "sheet.dart";
export "app_menu.dart";
export "app_menu_notifier.dart";
export "theme.dart";
export "app_frame_capture.dart";
export "package:flutter_lucide/flutter_lucide.dart";
+183
View File
@@ -0,0 +1,183 @@
import "package:flutter/services.dart";
/// What a mask slot will accept.
///
/// Anything in a mask that isnt one of these is a literal, inserted for you as
/// you type past it and never something you have to enter yourself.
enum MaskSlot {
/// `#` — 0-9.
digit("#"),
/// `A` — a letter, either case.
letter("A"),
/// `*` — a letter or a digit.
alphanumeric("*");
const MaskSlot(this.token);
final String token;
bool accepts(String ch) => switch (this) {
MaskSlot.digit => _isDigit(ch),
MaskSlot.letter => _isLetter(ch),
MaskSlot.alphanumeric => _isDigit(ch) || _isLetter(ch),
};
static MaskSlot? of(String ch) {
for (final s in MaskSlot.values) {
if (s.token == ch) return s;
}
return null;
}
}
bool _isDigit(String ch) {
final c = ch.codeUnitAt(0);
return c >= 0x30 && c <= 0x39;
}
bool _isLetter(String ch) {
final c = ch.codeUnitAt(0);
return (c >= 0x41 && c <= 0x5A) || (c >= 0x61 && c <= 0x7A);
}
/// Types a value into a fixed shape as you go — `### ###-####`, `AA# #AA`.
///
/// `#` takes a digit, `A` a letter, `*` either; everything else is a literal
/// that appears on its own once you've typed up to it. Input that doesnt fit a
/// slot is dropped rather than rejected wholesale, so a pasted
/// `(555) 123-4567` lands in `### ###-####` as `555 123-4567` instead of
/// nothing.
///
/// It works by reducing whatever the field now holds back to its *slot*
/// characters and re-laying the mask over them. That means one code path for
/// typing, pasting, deleting and dragging a selection, rather than four that
/// each have to agree — the failure mode of hand-rolled masks is usually that
/// they only got typing right.
///
/// The caveat that comes with any mask: the field's value is the FORMATTED
/// string. If what you store is the digits, strip it on the way out (see
/// [unmask]) rather than assuming the two are the same.
class MaskedTextInputFormatter extends TextInputFormatter {
MaskedTextInputFormatter(this.mask)
: assert(mask.length > 0, "an empty mask accepts nothing"),
_slots = [for (final ch in mask.split("")) MaskSlot.of(ch)] {
assert(
_slots.any((s) => s != null),
"a mask with no #, A or * is all literal - nothing could be typed",
);
}
/// e.g. `### ###-####`.
final String mask;
/// One entry per mask character: the slot it accepts, or null for a literal.
final List<MaskSlot?> _slots;
/// How many characters this mask can hold once full.
int get slotCount => _slots.where((s) => s != null).length;
/// The slot characters of [text], with the mask's own literals taken out.
///
/// The single reduction the whole formatter runs on: typing, pasting,
/// deleting and dragging a selection all come through here, so they cant
/// disagree with each other.
///
/// It walks the mask and the text together rather than filtering the text on
/// its own, which is what makes a literal that LOOKS like data behave - the
/// leading `1` of `1-###-####` is consumed as the literal it is instead of
/// being returned as the first digit somebody typed. Text that doesnt line
/// up (a pasted `(555) 123-4567`) just has the junk skipped.
String unmask(String text) {
final out = StringBuffer();
var i = 0; // into the mask
var t = 0; // into the text
while (i < _slots.length && t < text.length) {
final slot = _slots[i];
if (slot == null) {
// only step over the text's copy of this literal if its actually there
if (text[t] == mask[i]) t++;
i++;
continue;
}
if (slot.accepts(text[t])) {
out.write(text[t]);
i++;
}
t++;
}
return out.toString();
}
/// Lay the mask over [raw], stopping when either runs out.
///
/// Trailing literals are left off: a half typed `555` in `### ###-####` is
/// `555`, not `555 ` with a space you didnt ask for and cant delete.
String apply(String raw) {
final out = StringBuffer();
var r = 0;
for (var i = 0; i < _slots.length && r < raw.length; i++) {
if (_slots[i] == null) {
out.write(mask[i]);
continue;
}
out.write(raw[r]);
r++;
}
return out.toString();
}
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
// reduce to slot characters, ignoring where the literals were - this is
// what makes paste and drag-select behave the same as typing.
var raw = unmask(newValue.text);
// Deleting a literal has to delete something, or backspace looks frozen:
// "555 1" backspace kills the space, the slot characters are unchanged,
// and re-applying puts the space straight back. So when a delete didnt
// change the slot characters, take the one before the caret too.
final deleted = newValue.text.length < oldValue.text.length;
if (deleted && raw == unmask(oldValue.text) && raw.isNotEmpty) {
final upTo = unmask(
newValue.text.substring(
0,
newValue.selection.baseOffset.clamp(0, newValue.text.length),
),
).length;
final cut = upTo > 0 ? upTo - 1 : 0;
raw = raw.substring(0, cut) + raw.substring(cut + 1);
}
final formatted = apply(raw);
// put the caret after the same number of slot characters it was after
// before, which is the only position that survives literals moving.
final before = unmask(
newValue.text.substring(
0,
newValue.selection.baseOffset.clamp(0, newValue.text.length),
),
).length;
var offset = formatted.length;
var seen = 0;
for (var i = 0; i < formatted.length; i++) {
if (seen == before) {
offset = i;
break;
}
if (_slots[i] != null) seen++;
}
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(
offset: offset.clamp(0, formatted.length),
),
);
}
}
File diff suppressed because it is too large Load Diff
+257
View File
@@ -0,0 +1,257 @@
import "dart:math" as math;
import "package:flutter/services.dart" show LogicalKeyboardKey;
import "package:flutter/widgets.dart";
import "package:garage_ui/theme/garage_theme.dart";
// grab bag of the smaller shadcn widgets the app still reaches for. nothing
// fancy - just enough to match how they were used.
// shadcn's Scaffold gave the page a background + structure. the app only ever
// hands it a child, so thats all we do: fill with the theme background.
class Scaffold extends StatelessWidget {
const Scaffold({
super.key,
required this.child,
this.headers = const [],
this.footers = const [],
this.backgroundColor,
});
final Widget child;
final List<Widget> headers;
final List<Widget> footers;
final Color? backgroundColor;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final body = ColoredBox(
color: backgroundColor ?? theme.colorScheme.background,
child: child,
);
if (headers.isEmpty && footers.isEmpty) return body;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
...headers,
Expanded(child: body),
...footers,
],
);
}
}
/// A small indeterminate spinner with no Material dependency.
class CircularProgressIndicator extends StatefulWidget {
const CircularProgressIndicator({
super.key,
this.color,
this.strokeWidth = 4.0,
this.size = 18.0,
});
final Color? color;
final double strokeWidth;
final double size;
@override
State<CircularProgressIndicator> createState() =>
_CircularProgressIndicatorState();
}
class _CircularProgressIndicatorState extends State<CircularProgressIndicator>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
return SizedBox.square(
dimension: widget.size,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) => CustomPaint(
painter: _CircularProgressPainter(
progress: _controller.value,
color: widget.color ?? theme.colorScheme.primary,
strokeWidth: widget.strokeWidth,
),
),
),
);
}
}
class _CircularProgressPainter extends CustomPainter {
const _CircularProgressPainter({
required this.progress,
required this.color,
required this.strokeWidth,
});
final double progress;
final Color color;
final double strokeWidth;
@override
void paint(Canvas canvas, Size size) {
final inset = strokeWidth / 2;
final rect =
Offset(inset, inset) & Size.square(size.shortestSide - strokeWidth);
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeWidth = strokeWidth;
canvas.drawArc(
rect,
-math.pi / 2 + progress * math.pi * 2,
math.pi * 1.35,
false,
paint,
);
}
@override
bool shouldRepaint(_CircularProgressPainter oldDelegate) =>
oldDelegate.progress != progress ||
oldDelegate.color != color ||
oldDelegate.strokeWidth != strokeWidth;
}
// renders a keyboard shortcut as little key caps. `keys` is usually a list of
// LogicalKeyboardKey but we tolerate anything (some call sites pass strings).
class KeyboardDisplay extends StatelessWidget {
const KeyboardDisplay({super.key, required this.keys, this.spacing = 4});
final List<dynamic> keys;
final double spacing;
String _label(dynamic k) {
if (k is LogicalKeyboardKey) {
final l = k.keyLabel;
return l.isNotEmpty ? l : k.debugName ?? "?";
}
return "$k";
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scaling = theme.scaling;
final caps = <Widget>[];
for (var i = 0; i < keys.length; i++) {
if (i > 0) caps.add(SizedBox(width: spacing * scaling));
caps.add(
Container(
padding: EdgeInsets.symmetric(
horizontal: 5 * scaling,
vertical: 2 * scaling,
),
decoration: BoxDecoration(
color: theme.colorScheme.muted,
borderRadius: theme.borderRadiusSm,
border: Border.all(color: theme.colorScheme.border, width: scaling),
),
child: DefaultTextStyle.merge(
style: TextStyle(color: theme.colorScheme.mutedForeground),
child: Text(_label(keys[i])),
),
),
);
}
return Row(mainAxisSize: MainAxisSize.min, children: caps);
}
}
// shows [hoverBuilder]'s content floating near the child while hovered. shadcn's
// HoverCard, trimmed to a plain overlay follower.
class HoverCard extends StatefulWidget {
const HoverCard({
super.key,
required this.child,
required this.hoverBuilder,
this.anchorAlignment = Alignment.bottomLeft,
this.cardAlignment = Alignment.topLeft,
this.waitDuration = const Duration(milliseconds: 300),
});
final Widget child;
final WidgetBuilder hoverBuilder;
final Alignment anchorAlignment;
final Alignment cardAlignment;
final Duration waitDuration;
@override
State<HoverCard> createState() => _HoverCardState();
}
class _HoverCardState extends State<HoverCard> {
final LayerLink _link = LayerLink();
OverlayEntry? _entry;
void _show() {
if (_entry != null) return;
final overlay = Overlay.of(context);
_entry = OverlayEntry(
// needs an explicit origin: a Positioned with all-null coords is a
// non-positioned overlay child and gets laid out with TIGHT full-screen
// constraints, which blows any sized() card up to the whole screen. the
// follower does the real placement via the link regardless.
builder: (ctx) => Positioned(
left: 0,
top: 0,
child: CompositedTransformFollower(
link: _link,
showWhenUnlinked: false,
targetAnchor: widget.anchorAlignment,
followerAnchor: widget.cardAlignment,
child: MouseRegion(
onExit: (_) => _hide(),
child: widget.hoverBuilder(ctx),
),
),
),
);
overlay.insert(_entry!);
}
void _hide() {
_entry?.remove();
_entry = null;
}
@override
void dispose() {
_hide();
super.dispose();
}
@override
Widget build(BuildContext context) {
return CompositedTransformTarget(
link: _link,
child: MouseRegion(
onEnter: (_) => _show(),
onExit: (_) {
// give the pointer a beat to reach the card before we yank it.
Future.delayed(const Duration(milliseconds: 60), () {
if (mounted && _entry != null) _hide();
});
},
child: widget.child,
),
);
}
}
+600
View File
@@ -0,0 +1,600 @@
// GarageUI — navigation groups + progress indicators.
//
// used to be a near-verbatim shadcn fork with the whole sliver/Data/overflow
// machinery bolted on. the app only ever leans on a handful of these, so this
// is the trimmed, standalone version: plain flutter widgets + GarageTheme. no
// Data lookups, no NavigationControlData plumbing, no sliver headers.
import "package:flutter/widgets.dart";
import "package:garage_ui/theme/garage_theme.dart";
import "package:garage_ui/theme/support.dart";
import "package:garage_ui/button.dart";
/// Determines when labels are shown in navigation items.
enum NavigationLabelType {
/// No labels displayed.
none,
/// Labels shown only for selected items.
selected,
/// Labels always shown for all items.
all,
/// Labels shown as tooltips on hover.
tooltip,
/// Labels shown when navigation is expanded.
expanded,
}
/// Position of navigation item labels relative to the children.
enum NavigationLabelPosition {
/// Label before the items (left in LTR).
start,
/// Label after the items (right in LTR).
end,
/// Label above the items.
top,
/// Label below the items.
bottom,
}
/// Visual divider between navigation items.
///
/// Renders a thin horizontal line separator. The old version flipped direction
/// off inherited nav data and could emit a sliver — we only ever use it inside
/// vertical rails, so it's just a padded rule now.
class NavigationDivider extends StatelessWidget {
/// Optional thickness of the divider line.
final double? thickness;
/// Optional color for the divider.
final Color? color;
/// Creates a navigation divider.
const NavigationDivider({super.key, this.thickness, this.color});
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scaling = theme.scaling;
final line = Container(
height: thickness ?? (1 * scaling),
color: color ?? theme.colorScheme.divider,
);
return Padding(
padding: EdgeInsets.symmetric(vertical: theme.density.containerGap * 0.5),
child: line,
);
}
}
/// Groups navigation children under a label header.
///
/// Vertical column only — a padded label, a gap, then the children stretched to
/// fill. Label can sit before or after the items via [labelPosition].
class NavigationGroup extends StatelessWidget {
/// Label widget shown for the group.
final Widget label;
/// The child items within this group.
final List<Widget> children;
/// Position of the label relative to the children.
final NavigationLabelPosition labelPosition;
/// Alignment of the label content.
final AlignmentGeometry? labelAlignment;
/// Padding around the label.
final EdgeInsetsGeometry? labelPadding;
/// Creates a new navigation group.
const NavigationGroup({
super.key,
required this.label,
this.children = const [],
this.labelPosition = NavigationLabelPosition.top,
this.labelAlignment,
this.labelPadding,
});
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
// label sits at half the container padding, items get a full gap under it.
final contentPadding = theme.density.containerPadding;
final gap = theme.density.containerGap;
final paddedLabel = Container(
alignment: labelAlignment ?? Alignment.center,
padding:
labelPadding ??
EdgeInsets.symmetric(horizontal: contentPadding * 0.5),
child: DefaultTextStyle.merge(
textAlign: TextAlign.center,
maxLines: 1,
child: label,
),
);
final items = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
);
final labelFirst =
labelPosition == NavigationLabelPosition.top ||
labelPosition == NavigationLabelPosition.start;
final ordered = labelFirst
? [paddedLabel, SizedBox(height: gap), items]
: [items, SizedBox(height: gap), paddedLabel];
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: ordered,
);
}
}
/// Duration of one indeterminate sweep cycle.
const int _kIndeterminateLinearDuration = 1800;
/// A linear progress bar with determinate + indeterminate modes.
///
/// Determinate ([value] != null) fills left-to-right and eases toward each new
/// value. Indeterminate ([value] == null) runs two overlapping segments across
/// the track forever. Sparks/rtl/theme-lookup extras from the shadcn original
/// were dropped — nothing in the app used them.
class LinearProgressIndicator extends StatefulWidget {
// timing curves for the twin-line indeterminate motion, lifted straight from
// material so the sweep feels the same.
static const Curve _line1Head = Interval(
0.0,
750.0 / _kIndeterminateLinearDuration,
curve: Cubic(0.2, 0.0, 0.8, 1.0),
);
static const Curve _line1Tail = Interval(
333.0 / _kIndeterminateLinearDuration,
(333.0 + 750.0) / _kIndeterminateLinearDuration,
curve: Cubic(0.4, 0.0, 1.0, 1.0),
);
static const Curve _line2Head = Interval(
1000.0 / _kIndeterminateLinearDuration,
(1000.0 + 567.0) / _kIndeterminateLinearDuration,
curve: Cubic(0.0, 0.0, 0.65, 1.0),
);
static const Curve _line2Tail = Interval(
1267.0 / _kIndeterminateLinearDuration,
(1267.0 + 533.0) / _kIndeterminateLinearDuration,
curve: Cubic(0.10, 0.0, 0.45, 1.0),
);
/// Progress between 0.0 and 1.0. Null => indeterminate.
final double? value;
/// Background color of the track.
final Color? backgroundColor;
/// Minimum height of the bar.
final double? minHeight;
/// Primary color of the fill.
final Color? color;
/// Border radius of the container.
final BorderRadiusGeometry? borderRadius;
/// Creates a [LinearProgressIndicator].
const LinearProgressIndicator({
super.key,
this.value,
this.backgroundColor,
this.minHeight,
this.color,
this.borderRadius,
});
@override
State<LinearProgressIndicator> createState() =>
_LinearProgressIndicatorState();
}
class _LinearProgressIndicatorState extends State<LinearProgressIndicator>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: _kIndeterminateLinearDuration),
);
_syncController();
}
@override
void didUpdateWidget(covariant LinearProgressIndicator oldWidget) {
super.didUpdateWidget(oldWidget);
// only the indeterminate mode wants a ticking controller. flip it on/off
// when the value nullability changes so we're not spinning for nothing.
if ((oldWidget.value == null) != (widget.value == null)) {
_syncController();
}
}
void _syncController() {
if (widget.value == null) {
_controller.repeat();
} else {
_controller.stop();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final colorValue = widget.color ?? theme.colorScheme.primary;
final backgroundColorValue =
widget.backgroundColor ?? colorValue.scaleAlpha(0.2);
final minHeightValue = widget.minHeight ?? theme.scaling * 2;
final borderRadiusValue = widget.borderRadius ?? BorderRadius.zero;
Widget bar;
if (widget.value != null) {
// determinate — ease toward the new fill whenever value changes
bar = TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: widget.value!.clamp(0.0, 1.0)),
duration: kDefaultDuration,
curve: Curves.easeInOut,
builder: (context, v, _) {
return CustomPaint(
painter: _LinearProgressPainter(
end: v,
color: colorValue,
backgroundColor: backgroundColorValue,
),
);
},
);
} else {
// indeterminate — two overlapping segments chasing across the track
bar = AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final t = _controller.value;
return CustomPaint(
painter: _LinearProgressPainter(
start: LinearProgressIndicator._line1Tail.transform(t),
end: LinearProgressIndicator._line1Head.transform(t),
start2: LinearProgressIndicator._line2Tail.transform(t),
end2: LinearProgressIndicator._line2Head.transform(t),
color: colorValue,
backgroundColor: backgroundColorValue,
),
);
},
);
}
return RepaintBoundary(
child: SizedBox(
height: minHeightValue,
child: ClipRRect(borderRadius: borderRadiusValue, child: bar),
),
);
}
}
class _LinearProgressPainter extends CustomPainter {
final double start;
final double end;
final double? start2; // for the second indeterminate segment
final double? end2;
final Color color;
final Color backgroundColor;
_LinearProgressPainter({
this.start = 0,
required this.end,
this.start2,
this.end2,
required this.color,
required this.backgroundColor,
});
@override
void paint(Canvas canvas, Size size) {
var start = this.start;
var end = this.end;
var start2 = this.start2;
var end2 = this.end2;
// nan sneaks in from curve maths at the extremes — clamp it to nothing
if (start.isNaN) start = 0;
if (end.isNaN) end = 0;
if (start2 != null && start2.isNaN) start2 = 0;
if (end2 != null && end2.isNaN) end2 = 0;
final paint = Paint()..style = PaintingStyle.fill;
paint.color = backgroundColor;
canvas.drawRRect(
RRect.fromLTRBR(
0,
0,
size.width,
size.height,
Radius.circular(size.height / 2),
),
paint,
);
paint.color = color;
canvas.drawRect(
Rect.fromLTWH(
size.width * start,
0,
size.width * (end - start),
size.height,
),
paint,
);
if (start2 != null && end2 != null) {
canvas.drawRect(
Rect.fromLTWH(
size.width * start2,
0,
size.width * (end2 - start2),
size.height,
),
paint,
);
}
}
@override
bool shouldRepaint(covariant _LinearProgressPainter old) {
return old.start != start ||
old.end != end ||
old.start2 != start2 ||
old.end2 != end2 ||
old.color != color ||
old.backgroundColor != backgroundColor;
}
}
// ---------------------------------------------------------------------------
// NavigationRail — the little sidebar the settings panels use. collapses to
// icons, expands to icon + label. (shadcn had a much bigger rail; this is the
// slice the app actually drives.)
// ---------------------------------------------------------------------------
enum NavigationRailAlignment { start, center, end }
// carries the rail's expanded state + label side down to the items.
class _NavRailScope extends InheritedWidget {
const _NavRailScope({
required this.expanded,
required this.labelPosition,
required super.child,
});
final bool expanded;
final NavigationLabelPosition labelPosition;
static _NavRailScope? of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_NavRailScope>();
@override
bool updateShouldNotify(_NavRailScope old) =>
old.expanded != expanded || old.labelPosition != labelPosition;
}
class NavigationRail extends StatelessWidget {
const NavigationRail({
super.key,
this.children = const [],
this.header = const [],
this.footer = const [],
this.backgroundColor,
this.labelType = NavigationLabelType.selected,
this.labelPosition = NavigationLabelPosition.bottom,
this.alignment = NavigationRailAlignment.start,
this.expanded = false,
this.expandedSize = 150,
this.collapsedSize = 50,
this.padding,
});
final List<Widget> children;
final List<Widget> header;
final List<Widget> footer;
final Color? backgroundColor;
final NavigationLabelType labelType;
final NavigationLabelPosition labelPosition;
final NavigationRailAlignment alignment;
final bool expanded;
final double expandedSize;
final double collapsedSize;
final EdgeInsetsGeometry? padding;
MainAxisAlignment get _mainAxis => switch (alignment) {
NavigationRailAlignment.start => MainAxisAlignment.start,
NavigationRailAlignment.center => MainAxisAlignment.center,
NavigationRailAlignment.end => MainAxisAlignment.end,
};
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
return _NavRailScope(
expanded: expanded,
labelPosition: labelPosition,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: (expanded ? expandedSize : collapsedSize) * 1.0,
color: backgroundColor ?? theme.colorScheme.secondary,
padding: padding ?? EdgeInsets.all(6 * theme.scaling),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: _mainAxis,
children: [
...header,
...children,
if (footer.isNotEmpty) const Spacer(),
...footer,
],
),
),
);
}
}
class NavigationItem extends StatelessWidget {
const NavigationItem({
super.key,
required this.child,
this.label,
this.selected = false,
this.onChanged,
this.selectedStyle,
});
final Widget child;
final Widget? label;
final bool selected;
final ValueChanged<bool>? onChanged;
final ButtonStyle? selectedStyle;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final expanded = _NavRailScope.of(context)?.expanded ?? false;
final fg = selected
? theme.colorScheme.primaryForeground
: theme.colorScheme.foreground;
Widget row = Row(
mainAxisAlignment: expanded
? MainAxisAlignment.start
: MainAxisAlignment.center,
children: [
IconTheme.merge(
data: IconThemeData(color: fg, size: theme.iconTheme.medium.size),
child: child,
),
if (expanded && label != null) ...[
SizedBox(width: theme.density.containerGap),
DefaultTextStyle.merge(
style: TextStyle(color: fg),
child: label!,
),
],
],
);
return Padding(
padding: EdgeInsets.symmetric(vertical: 2 * theme.scaling),
child: MergeSemantics(
child: Semantics(
container: true,
selected: selected,
inMutuallyExclusiveGroup: true,
onTap: () => onChanged?.call(!selected),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
onTap: () => onChanged?.call(!selected),
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 8 * theme.scaling,
vertical: 8 * theme.scaling,
),
decoration: BoxDecoration(
color: selected
? theme.colorScheme.primary
: const Color(0x00000000),
borderRadius: theme.borderRadiusMd,
),
child: row,
),
),
),
),
);
}
}
// a free-form rail row (custom leading + optional title + tap). used for the
// collapse toggle.
class NavigationSlot extends StatelessWidget {
const NavigationSlot({
super.key,
this.title,
this.leading,
this.onPressed,
this.alignment = Alignment.centerLeft,
});
final Widget? title;
final Widget? leading;
final VoidCallback? onPressed;
final Alignment alignment;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final expanded = _NavRailScope.of(context)?.expanded ?? false;
final isCenter = alignment.x == 0 && !expanded;
Widget row = Row(
mainAxisAlignment: isCenter
? MainAxisAlignment.center
: MainAxisAlignment.start,
children: [
if (leading != null) leading!,
if (expanded && title != null) ...[
SizedBox(width: theme.density.containerGap),
title!,
],
],
);
return Padding(
padding: EdgeInsets.symmetric(vertical: 2 * theme.scaling),
child: MergeSemantics(
child: Semantics(
container: true,
button: true,
onTap: onPressed,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
onTap: onPressed,
child: row,
),
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
import "dart:ui" show ImageFilter;
import "package:flutter/widgets.dart";
import "package:garage_ui/theme/garage_theme.dart";
/// The entrance a Garage pane dialog makes: blurred backdrop, dimmed scrim,
/// fade and scale in, width clamped to the viewport.
///
/// This lived twice in Arcs & Angles - once for the titled pane and once for
/// the Blender-language one - identical apart from the shell widget wrapped
/// around the content. The presentation is the generic half, so it lives here
/// and [builder] supplies the shell.
///
/// [builder] is handed an `onClose` that pops the route, or null when
/// [dismissible] is false, so the shell can wire its own close affordance.
/// Content pops with its own result the usual way.
Future<T?> showPaneOverlay<T>({
required BuildContext context,
required Widget Function(BuildContext context, VoidCallback? onClose) builder,
double maxWidth = 450,
bool dismissible = true,
String? barrierLabel,
Duration duration = const Duration(milliseconds: 180),
double blurSigma = 3,
double scrimOpacity = 0.35,
double enterScale = 0.93,
double viewportInset = 24,
}) {
// read before the route is pushed so it reflects the theme at the call site
// rather than whatever sits above the navigator.
final scrim = GarageTheme.of(context).colorScheme.background;
return showGeneralDialog<T>(
context: context,
barrierDismissible: false,
barrierColor: const Color(0x00000000),
barrierLabel: barrierLabel,
transitionDuration: duration,
pageBuilder: (ctx, anim, secondaryAnim) => const SizedBox.shrink(),
transitionBuilder: (ctx, anim, secondaryAnim, _) {
final fade = CurvedAnimation(parent: anim, curve: Curves.easeOut);
final scale = Tween<double>(
begin: enterScale,
end: 1.0,
).animate(CurvedAnimation(parent: anim, curve: Curves.easeOut));
final screenW = MediaQuery.sizeOf(ctx).width;
final maxW = (screenW - viewportInset).clamp(0.0, maxWidth);
void close() => Navigator.of(ctx).pop();
return Stack(
children: [
Positioned.fill(
child: FadeTransition(
opacity: fade,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
child: GestureDetector(
// a dismiss scrim, not a control - Escape and the dialog's
// own close button are the reachable ways out. Left in the
// tree it reads as a full-screen unnamed button.
excludeFromSemantics: true,
onTap: dismissible ? close : null,
child: ColoredBox(
color: scrim.withValues(alpha: scrimOpacity),
),
),
),
),
),
Center(
child: FadeTransition(
opacity: fade,
child: ScaleTransition(
scale: scale,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: maxW, maxWidth: maxW),
child: builder(ctx, dismissible ? close : null),
),
),
),
),
],
);
},
);
}
+91
View File
@@ -0,0 +1,91 @@
import "package:flutter/widgets.dart";
import "package:garage_ui/theme/garage_theme.dart";
// A chrome panel - the bordered, rounded surface the shell docks things in.
// Fills with the scheme's panel colour and lights its border with the
// highlighted colour when active.
//
// Two modes:
// - standalone: leave [active] null and it tracks its own hover.
// - controlled: pass [active] and the parent drives the lit state (used by the
// shell so it can keep the panels mutually exclusive - only one lit at once,
// which also dodges flutter dropping an onExit during a fast mouse move and
// leaving a panel stuck on).
class Panel extends StatefulWidget {
const Panel({
super.key,
required this.child,
this.active,
this.borderRadius,
this.borderWidth = 1.15,
this.clip = true,
this.clipBehavior = Clip.antiAlias,
});
final Widget child;
// null = self-track hover; non-null = parent controls the lit state.
final bool? active;
// null = use the theme's panelRadius.
final double? borderRadius;
final double borderWidth;
// clip the child to the rounded corners.
final bool clip;
final Clip clipBehavior;
@override
State<Panel> createState() => _PanelState();
}
class _PanelState extends State<Panel> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final cs = theme.colorScheme;
final controlled = widget.active != null;
final lit = controlled ? widget.active! : _hovered;
final radius = BorderRadius.circular(
widget.borderRadius ?? theme.panelRadius,
);
Widget content = widget.child;
if (widget.clip) {
final clipRadius = BorderRadius.circular(
((widget.borderRadius ?? theme.panelRadius) - 1)
.clamp(0, double.infinity)
.toDouble(),
);
content = ClipRRect(
borderRadius: clipRadius,
clipBehavior: widget.clipBehavior,
child: content,
);
}
Widget box = Container(
decoration: BoxDecoration(
color: cs.background,
borderRadius: radius,
border: Border.all(
color: lit ? cs.panelBorderHighlighted : cs.panelBorder,
width: widget.borderWidth,
),
),
child: content,
);
// controlled panels let the parent own the MouseRegion.
if (controlled) return box;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: box,
);
}
}
+63
View File
@@ -0,0 +1,63 @@
import "package:flutter/foundation.dart";
import "package:flutter/services.dart";
// hides the OS cursor and keeps it visually pinned at its start point for a
// drag - see macos/Runner/CursorLock.swift for the actual warp-and-suppress
// implementation. no native handler is registered on platforms other than
// macOS, so [lock]/[unlock] just fail (and get logged) instead of doing
// anything - callers dont need to guard by platform themselves, they just
// wont get live deltas or a frozen cursor there.
//
// Deltas come back over the SAME channel as a native-to-dart call rather
// than through Flutters own PointerMoveEvent - the native side intercepts
// and swallows the underlying mouse-dragged events entirely (see the Swift
// side for why), so Flutters own pointer delta cannot be trusted at all
// while locked.
class CursorLock {
CursorLock._();
static const _channel = MethodChannel("garage/cursor_lock");
// whether theres actually a native side listening. anywhere else the
// lock/unlock calls are no-ops that throw a MissingPluginException, and -
// more importantly - no deltas ever come back, so callers need to know to
// drive themselves off Flutters own pointer stream instead.
static bool get isSupported =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.macOS;
static void Function(double dx, double dy)? _onDelta;
static bool _handlerInstalled = false;
static void _ensureHandlerInstalled() {
if (_handlerInstalled) return;
_handlerInstalled = true;
_channel.setMethodCallHandler((call) async {
if (call.method != "scrubDelta") return;
final args = call.arguments as Map;
final dx = (args["dx"] as num).toDouble();
final dy = (args["dy"] as num).toDouble();
_onDelta?.call(dx, dy);
});
}
// locks the cursor and starts forwarding raw pointer deltas to [onDelta]
// until [unlock] is called. only one scrub can be active at a time (theres
// only one OS cursor to hide), so this just replaces whatever listener was
// there before.
static void lock(void Function(double dx, double dy) onDelta) {
_ensureHandlerInstalled();
_onDelta = onDelta;
if (!isSupported) return;
_channel.invokeMethod("lock").catchError((e, st) {
debugPrint("CursorLock.lock failed: $e\n$st");
});
}
static void unlock() {
_onDelta = null;
if (!isSupported) return;
_channel.invokeMethod("unlock").catchError((e, st) {
debugPrint("CursorLock.unlock failed: $e\n$st");
});
}
}
+23
View File
@@ -0,0 +1,23 @@
import "package:flutter/widgets.dart";
import "eyedropper_native.dart"
if (dart.library.html) "eyedropper_web.dart"
as impl;
// Screen-wide colour sampling, where the platform can actually do it.
//
// macOS gets NSColorSampler (see macos/Runner/ScreenColorSampler.swift) and
// Chromium browsers get the EyeDropper web api. Both of those draw their own
// magnifier loupe over the whole desktop and hand back a single colour when
// the user clicks, so theres no live preview to stream on the way - the
// preview IS the loupe.
//
// Everywhere else [nativeEyedropperAvailable] just answers false and callers
// fall back to sampling the apps own frame (see widgets/app_frame_capture.dart),
// which is window-only but works on every platform.
Future<bool> nativeEyedropperAvailable() => impl.nativeEyedropperAvailable();
/// Opens the system sampler and resolves with the picked colour, or null if
/// the user cancelled (escape) or something went wrong on the way.
Future<Color?> pickNativeScreenColor() => impl.pickNativeScreenColor();
@@ -0,0 +1,48 @@
import "package:flutter/foundation.dart";
import "package:flutter/services.dart";
import "package:flutter/widgets.dart";
// macOS side of the eyedropper. Everything else that isnt web lands here too
// and simply reports "no native sampler", which is the honest answer - theres
// no handler registered on windows/linux/ios/android.
const _channel = MethodChannel("garage/eyedropper");
bool? _cachedAvailability;
Future<bool> nativeEyedropperAvailable() async {
if (_cachedAvailability != null) return _cachedAvailability!;
if (defaultTargetPlatform != TargetPlatform.macOS) {
_cachedAvailability = false;
return false;
}
try {
_cachedAvailability = await _channel.invokeMethod<bool>("isAvailable");
} catch (error, stack) {
// an older macOS (or a runner thats never been rebuilt with the swift
// side in it) - not fatal, we just fall back to in-app sampling.
debugPrint("eyedropper: isAvailable failed: $error\n$stack");
_cachedAvailability = false;
}
return _cachedAvailability ?? false;
}
Future<Color?> pickNativeScreenColor() async {
try {
final picked = await _channel.invokeMapMethod<String, int>("pick");
if (picked == null) return null;
final r = picked["r"];
final g = picked["g"];
final b = picked["b"];
if (r == null || g == null || b == null) {
debugPrint("eyedropper: native pick returned a malformed colour $picked");
return null;
}
return Color.fromARGB(255, r, g, b);
} catch (error, stack) {
debugPrint("eyedropper: native pick failed: $error\n$stack");
return null;
}
}
@@ -0,0 +1,50 @@
// ignore_for_file: avoid_web_libraries_in_flutter
import "dart:js_interop";
import "dart:js_interop_unsafe";
import "package:flutter/widgets.dart";
// EyeDropper is a chromium-only api at the time of writing (no firefox, no
// safari), hence the feature check rather than just assuming its there on web.
// https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper
@JS("EyeDropper")
extension type _EyeDropper._(JSObject _) implements JSObject {
external _EyeDropper();
external JSPromise<_EyeDropperResult> open();
}
extension type _EyeDropperResult._(JSObject _) implements JSObject {
external String get sRGBHex;
}
Future<bool> nativeEyedropperAvailable() async =>
globalContext.has("EyeDropper");
Future<Color?> pickNativeScreenColor() async {
try {
final result = await _EyeDropper().open().toDart;
return _parseHex(result.sRGBHex);
} catch (error, stack) {
// cancelling with escape rejects the promise with an AbortError, so this
// path is completely normal - still worth a line in the console though.
debugPrint("eyedropper: web pick ended without a colour: $error\n$stack");
return null;
}
}
Color? _parseHex(String value) {
final hex = value.replaceAll("#", "");
if (hex.length != 6) {
debugPrint("eyedropper: unexpected sRGBHex \"$value\"");
return null;
}
final parsed = int.tryParse(hex, radix: 16);
if (parsed == null) {
debugPrint("eyedropper: could not parse sRGBHex \"$value\"");
return null;
}
return Color(0xFF000000 | parsed);
}
+160
View File
@@ -0,0 +1,160 @@
import "dart:math" as math;
import "package:flutter/widgets.dart";
/// Where a popover should actually be placed, once the space around its
/// trigger has been measured.
///
/// Feed [targetAnchor]/[followerAnchor]/[offset] straight into a
/// [CompositedTransformFollower], and clamp the popup's own box with
/// [maxWidth]/[maxHeight].
class PopoverPlacement {
const PopoverPlacement({
required this.targetAnchor,
required this.followerAnchor,
required this.offset,
required this.maxWidth,
required this.maxHeight,
required this.openBelow,
required this.openToRight,
});
final Alignment targetAnchor;
final Alignment followerAnchor;
final Offset offset;
final double maxWidth;
final double maxHeight;
/// Which way the flip actually resolved. Callers that draw something
/// direction-dependent (a tail, a chevron) need to know.
final bool openBelow;
final bool openToRight;
}
/// Which axis a popover moves along relative to its trigger.
enum PopoverAxis {
/// Drops below the trigger (or flips above). Selects, dropdowns, menubar
/// menus - the popup is stacked under the thing that opened it.
vertical,
/// Flies out to the side of the trigger (or flips to the other side).
/// Submenus - the popup sits beside the thing that opened it.
horizontal,
}
/// Works out where a popover fits.
///
/// The rule is "keep the preferred side unless it doesn't fit and the other
/// side is genuinely better" - NOT "flip whenever the preferred side is
/// tight". A popover that flips the moment it's a pixel short would jitter
/// between sides as the window resizes, and would flip even when the opposite
/// side has less room than the side it left.
///
/// [preferredSize] is what the popup would like; when neither side can give it
/// that, the roomier side wins and the returned max dimensions clamp the popup
/// to what's actually there. Pass [Size.zero] if the size isn't known up front
/// (a shrink-wrapping menu) - the flip then falls back to a pure
/// which-side-has-more-room comparison.
PopoverPlacement resolvePopoverPlacement({
required RenderBox target,
required RenderBox overlay,
required Size preferredSize,
PopoverAxis axis = PopoverAxis.vertical,
bool preferBelow = true,
bool preferRight = true,
double gap = 0.0,
double margin = 8.0,
}) {
final targetOffset = target.localToGlobal(Offset.zero, ancestor: overlay);
final overlaySize = overlay.size;
final targetSize = target.size;
final roomBelow = overlaySize.height - targetOffset.dy - targetSize.height;
final roomAbove = targetOffset.dy;
final roomRight = overlaySize.width - targetOffset.dx;
final roomLeft = targetOffset.dx + targetSize.width;
// for a horizontal popover the sideways room is measured from the trigger's
// EDGES (it sits beside the trigger), not from its near edge the way a
// vertically-stacked popover measures its own left/right alignment room.
final roomRightOfTarget =
overlaySize.width - targetOffset.dx - targetSize.width;
final roomLeftOfTarget = targetOffset.dx;
bool fits(double room, double needed) => room >= needed + margin;
late final bool openBelow;
late final bool openToRight;
switch (axis) {
case PopoverAxis.vertical:
final needed = preferredSize.height + gap;
openBelow = preferBelow
? (fits(roomBelow, needed) || roomBelow >= roomAbove)
: !(fits(roomAbove, needed) || roomAbove >= roomBelow);
// horizontal here is just which way the popup extends from its anchor
// corner; it never sits beside the trigger, so it measures from the
// near edge.
final neededW = preferredSize.width;
openToRight = preferRight
? (fits(roomRight, neededW) || roomRight >= roomLeft)
: !(fits(roomLeft, neededW) || roomLeft >= roomRight);
case PopoverAxis.horizontal:
final needed = preferredSize.width + gap;
openToRight = preferRight
? (fits(roomRightOfTarget, needed) ||
roomRightOfTarget >= roomLeftOfTarget)
: !(fits(roomLeftOfTarget, needed) ||
roomLeftOfTarget >= roomRightOfTarget);
final neededH = preferredSize.height;
openBelow = preferBelow
? (fits(roomBelow + targetSize.height, neededH) ||
roomBelow >= roomAbove)
: !(fits(roomAbove + targetSize.height, neededH) ||
roomAbove >= roomBelow);
}
final double availableHeight;
final double availableWidth;
switch (axis) {
case PopoverAxis.vertical:
availableHeight = (openBelow ? roomBelow : roomAbove) - gap - margin;
availableWidth = (openToRight ? roomRight : roomLeft) - margin;
case PopoverAxis.horizontal:
// a side-flying popup is free to run the full height of the overlay
// from wherever it starts, so its height budget is measured from the
// trigger's own top/bottom edge rather than past it
availableHeight =
(openBelow
? roomBelow + targetSize.height
: roomAbove + targetSize.height) -
margin;
availableWidth =
(openToRight ? roomRightOfTarget : roomLeftOfTarget) - gap - margin;
}
final Alignment targetAnchor;
final Alignment followerAnchor;
final Offset resolvedOffset;
switch (axis) {
case PopoverAxis.vertical:
targetAnchor = Alignment(openToRight ? -1 : 1, openBelow ? 1 : -1);
followerAnchor = Alignment(openToRight ? -1 : 1, openBelow ? -1 : 1);
resolvedOffset = Offset(0, openBelow ? gap : -gap);
case PopoverAxis.horizontal:
targetAnchor = Alignment(openToRight ? 1 : -1, openBelow ? -1 : 1);
followerAnchor = Alignment(openToRight ? -1 : 1, openBelow ? -1 : 1);
resolvedOffset = Offset(openToRight ? gap : -gap, 0);
}
return PopoverPlacement(
targetAnchor: targetAnchor,
followerAnchor: followerAnchor,
offset: resolvedOffset,
maxWidth: math.max(0.0, availableWidth),
maxHeight: math.max(0.0, availableHeight),
openBelow: openBelow,
openToRight: openToRight,
);
}
+940
View File
@@ -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,
);
}
}
+44
View File
@@ -0,0 +1,44 @@
import "package:flutter/widgets.dart";
/// Wraps a scrollable panel body so content approaching the top or bottom
/// edge fades to transparent over [fadeExtent] pixels instead of getting cut
/// off flat by the scroll view's own default hard clip. Purely a paint
/// effect - the scroll views clip still keeps everything contained, this
/// just masks the alpha near each edge on top of that.
///
/// Colour-blind on purpose: the gradient only carries alpha (dstIn blends
/// against whatever's already painted), so it works unchanged over any panel
/// background/scheme rather than needing its own colour token.
class ScrollEdgeFade extends StatelessWidget {
const ScrollEdgeFade({super.key, required this.child, this.fadeExtent = 6});
final Widget child;
final double fadeExtent;
@override
Widget build(BuildContext context) {
return ShaderMask(
shaderCallback: (bounds) {
// fraction of the box height the fade eats into from each end - a
// fixed pixel extent thats clamped so a very short panel doesnt end
// up with the two fades overlapping and cancelling out the middle.
final fraction = bounds.height > 0
? (fadeExtent / bounds.height).clamp(0.0, 0.5)
: 0.0;
return LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0.0, fraction, 1 - fraction, 1.0],
colors: const [
Color(0x00ffffff),
Color(0xffffffff),
Color(0xffffffff),
Color(0x00ffffff),
],
).createShader(bounds);
},
blendMode: BlendMode.dstIn,
child: child,
);
}
}
+167
View File
@@ -0,0 +1,167 @@
import "dart:ui" show lerpDouble;
import "package:flutter/widgets.dart";
import "package:garage_ui/theme/garage_theme.dart";
// Rest -> hover geometry. A hairline: at rest its barely a mark on the edge
// of the pane, and hover only just thickens it. The pill radius falls out of
// the thickness so the two never drift.
const double _restThickness = 2;
const double _hoverThickness = 3.5;
const double _restAlpha = 0.35;
const double _hoverAlpha = 0.55;
const double _dragAlpha = 0.7;
// how long the fatten/brighten takes. short - this is a pointer response, not
// a transition.
const Duration _grow = Duration(milliseconds: 140);
/// The Garage scroll thumb.
///
/// Every scrollable in a [GarageApp] gets one of these through
/// GarageScrollBehavior, so nothing has to wrap itself in a scrollbar by hand.
/// Flutters own default is a [RawScrollbar] in stock grey - square ended, 8px,
/// no relation to the scheme - which is what you were seeing before this
/// existed.
///
/// It floats OVER the content (no track, no gutter) so turning it on doesnt
/// reflow anything, and it holds itself visible while the pointer is anywhere
/// in the pane rather than only while youre actually scrolling.
class GarageScrollbar extends StatefulWidget {
const GarageScrollbar({super.key, required this.child, this.controller});
final Widget child;
/// The controller of the scrollable underneath. Comes straight off
/// ScrollableDetails when this is built by the scroll behaviour; null falls
/// back to the PrimaryScrollController the same way [RawScrollbar] does.
final ScrollController? controller;
@override
State<GarageScrollbar> createState() => _GarageScrollbarState();
}
class _GarageScrollbarState extends State<GarageScrollbar> {
bool _hovered = false;
bool _dragging = false;
ScrollController? get _controller =>
widget.controller ?? PrimaryScrollController.maybeOf(context);
// RawScrollbar only tolerates thumbVisibility when its controller is wired
// to exactly one live position - it asserts on everything else. We flip that
// flag on hover rather than at construction, so the same checks have to
// happen here first: a pane whose controller hasnt attached yet would
// otherwise throw the moment the pointer crossed it, and one whose content
// fits would paint a full height thumb over nothing.
bool get _canHold {
final c = _controller;
if (c == null || !c.hasClients || c.positions.length != 1) return false;
final p = c.position;
if (!p.hasContentDimensions) return false;
return p.maxScrollExtent > p.minScrollExtent;
}
void _setHovered(bool value) {
if (_hovered == value) return;
setState(() {
_hovered = value;
// a drag that ends outside the pane never reports back (the recognisers
// cancel path doesnt route through handleThumbPressEnd), so dont let the
// flag stick.
if (!value) _dragging = false;
});
}
void _setDragging(bool value) {
if (_dragging == value) return;
setState(() => _dragging = value);
}
@override
Widget build(BuildContext context) {
final scheme = GarageTheme.maybeOf(context)?.colorScheme;
// outside a GarageTheme (a bare test host, mostly) fall back to flutters
// own grey rather than asserting - a scrollbar is never worth a red screen.
final base = scheme?.mutedForeground ?? const Color(0xffbcbcbc);
final lit = _hovered || _dragging;
return MouseRegion(
opaque: false,
onEnter: (_) => _setHovered(true),
onExit: (_) => _setHovered(false),
child: Listener(
onPointerUp: (_) => _setDragging(false),
onPointerCancel: (_) => _setDragging(false),
child: TweenAnimationBuilder<double>(
tween: Tween<double>(end: lit ? 1 : 0),
duration: _grow,
curve: Curves.easeOut,
builder: (context, t, child) {
final thickness = lerpDouble(_restThickness, _hoverThickness, t)!;
final alpha = _dragging
? _dragAlpha
: lerpDouble(_restAlpha, _hoverAlpha, t)!;
return _GarageRawScrollbar(
controller: widget.controller,
thumbColor: base.withValues(alpha: alpha),
thickness: thickness,
radius: Radius.circular(thickness / 2),
// null, not false: false would mean "definitely hidden" and
// kill the fade in on scroll.
thumbVisibility: lit && _canHold ? true : null,
crossAxisMargin: 2,
mainAxisMargin: 2,
minThumbLength: 24,
onDragChanged: _setDragging,
child: child!,
);
},
child: widget.child,
),
),
);
}
}
// The only reason this subclass exists: RawScrollbar keeps its drag state to
// itself, and the thumb is meant to go full strength while youre holding it.
class _GarageRawScrollbar extends RawScrollbar {
const _GarageRawScrollbar({
required super.child,
required this.onDragChanged,
super.controller,
super.thumbVisibility,
super.thumbColor,
super.thickness,
super.radius,
super.crossAxisMargin,
super.mainAxisMargin,
super.minThumbLength,
});
final ValueChanged<bool> onDragChanged;
@override
RawScrollbarState<_GarageRawScrollbar> createState() =>
_GarageRawScrollbarState();
}
class _GarageRawScrollbarState extends RawScrollbarState<_GarageRawScrollbar> {
@override
void handleThumbPressStart(Offset localPosition) {
super.handleThumbPressStart(localPosition);
widget.onDragChanged(true);
}
@override
void handleThumbPressEnd(Offset localPosition, Velocity velocity) {
super.handleThumbPressEnd(localPosition, velocity);
widget.onDragChanged(false);
}
}
File diff suppressed because it is too large Load Diff
+533
View File
@@ -0,0 +1,533 @@
// hand rolled replacements for shadcn's Checkbox (tri-state) + Switch.
// started as a pixel-identical port of shadcn_flutter 0.0.52 form/checkbox.dart
// + form/switch.dart; the colours now come from the apps own control tokens
// (ColourScheme) rather than the shadcn scheme they were ported against.
import "package:flutter/services.dart";
import "package:flutter/widgets.dart";
import "package:garage_ui/semantics_scope.dart";
import "package:garage_ui/theme/garage_theme.dart";
/// how long a switch takes to slide/recolour. matches shadcn.
const kSwitchDuration = Duration(milliseconds: 100);
/// the three states a checkbox can be in. names match shadcn exactly so call
/// sites only have to swap their import.
enum CheckboxState implements Comparable<CheckboxState> {
checked,
unchecked,
indeterminate;
@override
int compareTo(CheckboxState other) {
return index.compareTo(other.index);
}
}
/// tri-state checkbox. drive it with [state] + [onChanged] — it does not hold
/// its own state, the parent does.
class Checkbox extends StatefulWidget {
final CheckboxState state;
final ValueChanged<CheckboxState>? onChanged;
final Widget? leading;
final Widget? trailing;
/// when true, tapping cycles checked -> unchecked -> indeterminate.
/// when false it just toggles checked/unchecked.
final bool tristate;
final bool? enabled;
final double? size;
final double? gap;
final Color? backgroundColor;
final Color? activeColor;
final Color? borderColor;
final BorderRadiusGeometry? borderRadius;
/// screen reader label. Needed unless [leading]/[trailing] already carry a
/// Text that names the box — the box itself paints a tick, which reads as
/// nothing at all.
final String? semanticLabel;
final FocusNode? focusNode;
const Checkbox({
super.key,
required this.state,
required this.onChanged,
this.leading,
this.trailing,
this.tristate = false,
this.enabled,
this.size,
this.gap,
this.backgroundColor,
this.activeColor,
this.borderColor,
this.borderRadius,
this.semanticLabel,
this.focusNode,
});
@override
State<Checkbox> createState() => _CheckboxState();
}
class _CheckboxState extends State<Checkbox> {
// shadcn keeps this always false (focus ring never wired), so the border
// width stays 1*scaling. we keep the same behaviour.
final bool _focusing = false;
bool _shouldAnimate = false;
void _changeTo(CheckboxState state) {
if (widget.onChanged != null) {
widget.onChanged!(state);
}
}
void _tap() {
if (widget.tristate) {
switch (widget.state) {
case CheckboxState.checked:
_changeTo(CheckboxState.unchecked);
break;
case CheckboxState.unchecked:
_changeTo(CheckboxState.indeterminate);
break;
case CheckboxState.indeterminate:
_changeTo(CheckboxState.checked);
break;
}
} else {
_changeTo(
widget.state == CheckboxState.checked
? CheckboxState.unchecked
: CheckboxState.checked,
);
}
}
@override
void didUpdateWidget(covariant Checkbox oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.state != oldWidget.state) {
_shouldAnimate = true;
}
}
bool get enabled => widget.enabled ?? widget.onChanged != null;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scaling = theme.scaling;
final size = widget.size ?? 16 * scaling;
final gap = widget.gap ?? 8 * scaling;
// a checkbox is a control, so it takes the control tokens: the same fill
// a secondary button/field uses, and the same border every other input
// draws. (was input.scaleAlpha(0.3) over colorScheme.border - both
// shadcn-migration leftovers that left it reading as its own family.)
final backgroundColor =
widget.backgroundColor ?? theme.colorScheme.secondary;
final activeColor = widget.activeColor ?? theme.colorScheme.primary;
final borderColor = widget.borderColor ?? theme.colorScheme.controlBorder;
final borderRadius =
widget.borderRadius?.resolve(Directionality.maybeOf(context)) ??
BorderRadius.circular(theme.radiusSm);
return MergeSemantics(
child: Semantics(
container: true,
enabled: enabled,
checked: widget.state == CheckboxState.checked,
// report mixed off the actual state, not off tristate — a box can be
// handed CheckboxState.indeterminate even when tapping it only ever
// toggles, and "partly on" is still what the user needs to hear.
mixed: widget.state == CheckboxState.indeterminate ? true : null,
label: resolveSemanticLabel(context, widget.semanticLabel),
onTap: enabled ? _tap : null,
child: _buildBox(
context,
size,
gap,
backgroundColor,
activeColor,
borderColor,
borderRadius,
theme,
scaling,
),
),
);
}
Widget _buildBox(
BuildContext context,
double size,
double gap,
Color backgroundColor,
Color activeColor,
Color borderColor,
BorderRadius borderRadius,
ThemeData theme,
double scaling,
) {
return FocusableActionDetector(
enabled: enabled,
focusNode: widget.focusNode,
mouseCursor: enabled
? SystemMouseCursors.click
: SystemMouseCursors.forbidden,
shortcuts: const {
SingleActivator(LogicalKeyboardKey.space): ActivateIntent(),
SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(),
},
actions: {
ActivateIntent: CallbackAction<ActivateIntent>(
onInvoke: (_) {
_tap();
return null;
},
),
},
child: GestureDetector(
onTap: enabled ? _tap : null,
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.leading != null) widget.leading!,
if (widget.leading != null) SizedBox(width: gap),
AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: size,
height: size,
decoration: BoxDecoration(
color: widget.state == CheckboxState.checked
? activeColor
: backgroundColor,
borderRadius: borderRadius,
border: Border.all(
color: !enabled
? theme.colorScheme.muted
: widget.state == CheckboxState.checked
? activeColor
: borderColor,
width: (_focusing ? 2 : 1) * scaling,
),
),
child: widget.state == CheckboxState.checked
? Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
child: SizedBox(
width: scaling * 9,
height: scaling * 6.5,
child: TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: _shouldAnimate ? 0.0 : 1.0,
end: 1.0,
),
duration: const Duration(milliseconds: 300),
// interval maps to shadcn's IntervalDuration(start: 175ms,
// duration: 300ms) — hold, then draw the tick.
curve: const Interval(175 / 300, 1.0),
builder: (context, value, child) {
return CustomPaint(
painter: AnimatedCheckPainter(
progress: value,
color: theme.colorScheme.primaryForeground,
strokeWidth: scaling * 1,
),
);
},
),
),
),
)
: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
width: widget.state == CheckboxState.indeterminate
? scaling * 8
: 0,
height: widget.state == CheckboxState.indeterminate
? scaling * 8
: 0,
padding: EdgeInsets.zero,
decoration: BoxDecoration(
color: activeColor,
borderRadius: BorderRadius.circular(theme.radiusXs),
),
),
),
),
if (widget.trailing != null) SizedBox(width: gap),
if (widget.trailing != null) widget.trailing!,
],
),
),
);
}
}
/// draws the little tick that animates in when a checkbox goes checked.
/// copied verbatim from shadcn so the stroke geometry stays identical.
class AnimatedCheckPainter extends CustomPainter {
final double progress;
final Color color;
final double strokeWidth;
AnimatedCheckPainter({
required this.progress,
required this.color,
required this.strokeWidth,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final path = Path();
Offset firstStrokeStart = Offset(0, size.height * 0.5);
Offset firstStrokeEnd = Offset(size.width * 0.35, size.height);
Offset secondStrokeStart = firstStrokeEnd;
Offset secondStrokeEnd = Offset(size.width, 0);
double firstStrokeLength =
(firstStrokeEnd - firstStrokeStart).distanceSquared;
double secondStrokeLength =
(secondStrokeEnd - secondStrokeStart).distanceSquared;
double totalLength = firstStrokeLength + secondStrokeLength;
double normalizedFirstStrokeLength = firstStrokeLength / totalLength;
double normalizedSecondStrokeLength = secondStrokeLength / totalLength;
double firstStrokeProgress =
progress.clamp(0.0, normalizedFirstStrokeLength) /
normalizedFirstStrokeLength;
double secondStrokeProgress =
(progress - normalizedFirstStrokeLength).clamp(
0.0,
normalizedSecondStrokeLength,
) /
normalizedSecondStrokeLength;
if (firstStrokeProgress <= 0) {
return;
}
Offset currentPoint = Offset.lerp(
firstStrokeStart,
firstStrokeEnd,
firstStrokeProgress,
)!;
path.moveTo(firstStrokeStart.dx, firstStrokeStart.dy);
path.lineTo(currentPoint.dx, currentPoint.dy);
if (secondStrokeProgress <= 0) {
canvas.drawPath(path, paint);
return;
}
Offset secondPoint = Offset.lerp(
secondStrokeStart,
secondStrokeEnd,
secondStrokeProgress,
)!;
path.lineTo(secondPoint.dx, secondPoint.dy);
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant AnimatedCheckPainter oldDelegate) {
return oldDelegate.progress != progress ||
oldDelegate.color != color ||
oldDelegate.strokeWidth != strokeWidth;
}
}
/// sliding on/off toggle. controlled — [value] in, [onChanged] out.
class Switch extends StatefulWidget {
final bool value;
final ValueChanged<bool>? onChanged;
final Widget? leading;
final Widget? trailing;
final bool? enabled;
final double? gap;
final Color? activeColor;
final Color? inactiveColor;
final Color? activeThumbColor;
final Color? inactiveThumbColor;
final BorderRadiusGeometry? borderRadius;
/// screen reader label. Same story as Checkbox — the track and thumb are
/// pure paint, so without this the switch is an unnamed toggle.
final String? semanticLabel;
const Switch({
super.key,
required this.value,
required this.onChanged,
this.leading,
this.trailing,
this.enabled = true,
this.gap,
this.activeColor,
this.inactiveColor,
this.activeThumbColor,
this.inactiveThumbColor,
this.borderRadius,
this.semanticLabel,
});
@override
State<Switch> createState() => _SwitchState();
}
class _SwitchState extends State<Switch> {
bool get _enabled => widget.enabled ?? widget.onChanged != null;
void _toggle() {
widget.onChanged?.call(!widget.value);
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scaling = theme.scaling;
final densityGap = theme.density.containerGap;
final gap = widget.gap ?? 8 * scaling;
final activeColor = widget.activeColor ?? theme.colorScheme.primary;
final inactiveColor =
widget.inactiveColor ?? theme.colorScheme.switchTrackInactive;
final activeThumbColor =
widget.activeThumbColor ?? theme.colorScheme.background;
final inactiveThumbColor =
widget.inactiveThumbColor ?? theme.colorScheme.foreground;
final borderRadius =
widget.borderRadius?.resolve(Directionality.maybeOf(context)) ??
BorderRadius.circular(theme.radiusXl);
return MergeSemantics(
child: Semantics(
container: true,
toggled: widget.value,
enabled: _enabled,
label: resolveSemanticLabel(context, widget.semanticLabel),
onTap: _enabled ? _toggle : null,
child: _buildTrack(
context,
theme,
scaling,
densityGap,
gap,
activeColor,
inactiveColor,
activeThumbColor,
inactiveThumbColor,
borderRadius,
),
),
);
}
Widget _buildTrack(
BuildContext context,
ThemeData theme,
double scaling,
double densityGap,
double gap,
Color activeColor,
Color inactiveColor,
Color activeThumbColor,
Color inactiveThumbColor,
BorderRadius borderRadius,
) {
return GestureDetector(
onTap: _enabled ? _toggle : null,
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
child: FocusableActionDetector(
enabled: _enabled,
actions: {
ActivateIntent: CallbackAction<Intent>(
onInvoke: (intent) {
_toggle();
return true;
},
),
},
shortcuts: const {
SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(),
SingleActivator(LogicalKeyboardKey.space): ActivateIntent(),
},
mouseCursor: _enabled
? SystemMouseCursors.click
: SystemMouseCursors.forbidden,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.leading != null) widget.leading!,
if (widget.leading != null) SizedBox(width: gap),
AnimatedContainer(
duration: kSwitchDuration,
width: (32 + 4) * scaling,
height: (16 + 4) * scaling,
padding: EdgeInsets.all(densityGap * 0.25),
decoration: BoxDecoration(
borderRadius: borderRadius,
color: !_enabled
? theme.colorScheme.muted
: widget.value
? activeColor
: inactiveColor,
),
child: Stack(
children: [
AnimatedPositioned(
duration: kSwitchDuration,
curve: Curves.easeInOut,
left: widget.value ? 16 * scaling : 0,
top: 0,
bottom: 0,
child: AspectRatio(
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(theme.radiusLg),
color: !_enabled
? theme.colorScheme.mutedForeground
: widget.value
? activeThumbColor
: inactiveThumbColor,
),
),
),
),
],
),
),
if (widget.trailing != null) SizedBox(width: gap),
if (widget.trailing != null) widget.trailing!,
],
),
),
);
}
}
+71
View File
@@ -0,0 +1,71 @@
// A label handed down to whatever control sits in a labelled row.
//
// Most controls in this app are not labelled at the call site — they sit in a
// PropertyRow (or a settings row) that already draws the name beside them, so
// a sighted user reads "Show grid" and then the box. A screen reader walking
// the tree gets the Text and the checkbox as two unrelated things, and the
// checkbox itself says nothing.
//
// The obvious fix — merging the whole row into one semantics node — falls over
// on rows holding more than one control (a Size row is two fields in a button
// group; merged, it becomes one unreadable blob). So instead the row publishes
// its label down the subtree and each control picks it up as a FALLBACK for
// its own semanticLabel. Explicit always wins.
import "package:flutter/widgets.dart";
class PropertyLabelScope extends InheritedWidget {
const PropertyLabelScope({
super.key,
required this.label,
required super.child,
});
final String label;
/// nearest enclosing row label, or null if this control isn't in one.
static String? maybeOf(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<PropertyLabelScope>()
?.label;
}
@override
bool updateShouldNotify(PropertyLabelScope old) => label != old.label;
}
/// Resolves the label a control should announce: its own if it was given one,
/// otherwise the row it lives in. Returns null when neither exists, which is
/// the case a control should be given an explicit label to fix.
String? resolveSemanticLabel(BuildContext context, String? explicit) {
if (explicit != null) return explicit;
return PropertyLabelScope.maybeOf(context);
}
// Marks the control slot of a labelled row, so the controls inside it can stop
// asking the call site what they should look like.
//
// A property section is a docked surface: its rows sit ON a panel, not on the
// page. An outline field in there reads as floating - it draws its own border
// and background against a background that already has one. Secondary is the
// pairing that reads as docked, and it is the ONLY correct answer inside a
// row, which makes `variant:` at those call sites a parameter whose every
// value but one is a bug.
//
// So the row publishes the fact, and TextField/Select resolve against it. This
// is the same trick PropertyLabelScope plays one widget up - the row knows
// something the control cant see, and hands it down rather than making every
// call site repeat it. Unlike the label, though, this one is NOT a fallback:
// an explicit `variant:` inside a row loses. Thats the point.
class PropertySlotScope extends InheritedWidget {
const PropertySlotScope({super.key, required super.child});
/// true when this control sits in a property row's control slot.
static bool of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<PropertySlotScope>() !=
null;
}
@override
bool updateShouldNotify(PropertySlotScope old) => false;
}
+303
View File
@@ -0,0 +1,303 @@
import "package:flutter/widgets.dart";
import "field_error.dart";
import "overlay.dart" show Tooltip;
import "surface.dart" show Divider;
import "theme/garage_theme.dart";
/// A list of label / field pairs, ruled between entries.
///
/// The other way of showing a set of values is [PropertiesSection], which puts
/// them in a bordered, collapsible card. That card is a subject - a thing with
/// a name and a state you can fold away - and a page made of nothing but those
/// reads as a stack of boxes rather than a set of settings, which is the
/// complaint that produced this.
///
/// Here theres no box and no header. Just rows with a hairline between them,
/// so what you see is the values and where one ends and the next begins.
///
/// Not a scroller: it takes its height from its rows and expects a pane to do
/// the scrolling, the same as a run of sections does.
class SettingsList extends StatelessWidget {
const SettingsList({super.key, required this.children});
/// [SettingsRow]s, usually. Anything else is laid out and ruled the same.
final List<Widget> children;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scheme = theme.colorScheme;
final inset = theme.density.gapSm;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < children.length; i++) ...[
// BETWEEN entries, not around them. A rule above the first row or
// under the last one draws a box, which is the thing this exists to
// not be.
//
// And the rule runs WIDER than the rows: the inset is on the rows,
// not on the list, so the line reaches slightly past the text at
// both ends. A rule that stops exactly where its content stops
// reads as the edge of a box; one that overshoots reads as a
// divider between two things.
if (i > 0) Divider(color: scheme.divider),
Padding(
padding: EdgeInsets.symmetric(horizontal: inset),
child: children[i],
),
],
],
);
}
}
/// One label and one field, on a line.
class SettingsRow extends StatelessWidget {
const SettingsRow({
super.key,
required this.label,
required this.field,
this.subtitle,
this.description,
this.error,
this.action,
this.labelTooltip,
});
/// Left, and it gets whatever width the field doesnt want.
final String label;
/// Shown on hovering the label, and only the label.
///
/// For the exact thing the label is a friendly name for - a permission
/// string, an id, a unit. That belongs somewhere you can go and look for
/// it rather than in the row, where it would be a second piece of text
/// competing with the one a person actually reads.
///
/// It wraps the label text itself, not its half of the row, so the empty
/// space beside a short label doesnt trigger anything.
final WidgetBuilder? labelTooltip;
/// A second line under the label, for the words the label had to leave out.
///
/// A QUALIFIER, not an explanation - "Account, passkeys and sign-in
/// activity" under "Read", not a sentence about what reading means. If it
/// needs a sentence it isnt a settings row.
final String? subtitle;
/// A full width line UNDER the whole row, label and field both.
///
/// For copy about the setting rather than about the field - consequences,
/// caveats, what changes when you change it. [subtitle] is a qualifier and
/// has to fit beside a value; this is a paragraph and doesnt.
///
/// The same slot [PropertyRow] has, for the same reason: a sentence squashed
/// into the label column is a sentence nobody reads.
final String? description;
/// Why the value was refused, under the row and in the destructive colour.
///
/// It also reddens the field's outline, through [FieldErrorScope] - so the
/// row says which one and this says why, which is the pair a toast cant be:
/// a message that floats over the corner of the screen has left the field
/// it was about behind.
final String? error;
/// Beside the label, on its line. The onboarding flow's ProductField has the
/// same slot and puts a muted "Optional" in it; a settings row gets one so
/// "Required" doesnt have to be found out by pressing Save.
///
/// Styled here rather than by the caller: it reads at the subtitle's size
/// and colour, so its a tag ON the label rather than a second label. A
/// caller that sets its own style still wins - this only supplies the
/// default.
final Widget? action;
/// Right, at its own size.
///
/// It is NOT stretched to a column: a Select that says "Engineering" should
/// be as wide as "Engineering", and a text box thats meant to be wide can
/// say so with a SizedBox. Stretching everything to one split is what makes
/// a form of mixed controls look like a table with a ragged edge.
final Widget field;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final density = theme.density;
// textXs over textXxs - the pairing density calls a "label column" in so
// many words, and the one the consent screen reads right at.
//
// The label was inheriting the ambient body size, which is the CONTROL
// font. Against a textXxs subtitle thats 1.0 to 0.875, near enough the
// same text twice, and the label stopped reading as the name of anything.
// A step up puts it at 1.1 to 0.875 and the two lines have different jobs
// again.
final labelStyle = theme.typography.normal.copyWith(
fontSize: density.textXs,
color: theme.colorScheme.foreground,
);
Widget labelText = Text(label, style: labelStyle);
if (labelTooltip case final tip?) {
labelText = Tooltip(tooltip: tip, child: labelText);
}
final aside = action;
if (aside != null) {
labelText = Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// flexible, so a long label ellipsises rather than shoving the tag
// off the end of the column
Flexible(child: labelText),
SizedBox(width: density.gapXs),
DefaultTextStyle.merge(
style: TextStyle(
fontSize: density.textXxs,
color: theme.colorScheme.mutedForeground,
fontWeight: FontWeight.normal,
),
child: aside,
),
],
);
}
final row = Padding(
padding: EdgeInsets.only(
top: density.gapMd,
bottom: description == null && error == null
? density.gapMd
: density.gapSm,
),
child: ConstrainedBox(
// A MINIMUM, and its the height of a row that HAS a subtitle.
//
// Otherwise a list where only some rows carry a second line comes out
// ragged - the plain ones close up to a single line box and the run
// of rows has two rhythms in it. labelColumnHeight is that stack
// exactly (textXs over textXxs on the font's own line box), plus the
// gap this row puts between the two.
//
// Rows that are legitimately taller - a four line text box - grow
// past it untouched, which a fixed height would squash.
constraints: BoxConstraints(
minHeight: density.labelColumnHeight + density.gapXxs,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
labelText,
if (subtitle != null) ...[
// the two lines were touching. A caption sat hard against
// the thing it captions reads as a wrapped second line of
// it rather than as a note under it.
SizedBox(height: density.gapXxs),
Text(
subtitle!,
style: TextStyle(
fontSize: density.textXxs,
color: theme.colorScheme.mutedForeground,
),
),
],
],
),
),
SizedBox(width: density.gapMd),
// Flexible, loose - so the field is BOUNDED but still sizes itself.
//
// A bare child in a Row is measured with an unbounded main axis,
// and anything with a flex child inside it - a ButtonGroup with
// `fill`, a Row of Expanded - asserts the moment it sees that. As a
// loose Flexible it gets "at most whats left", so a Text or a Select
// still shrink wraps and a filled control has a width to divide.
//
// Aligned right INSIDE that slot, because the slot is a share of the
// row rather than the width of the field. Without this a select that
// wants 90px sits at the left edge of its half and lands in the
// middle of the line, which is neither one column nor the other.
Flexible(
child: Align(
alignment: Alignment.centerRight,
child: FieldErrorScope(invalid: error != null, child: field),
),
),
],
),
),
);
final note = description;
final complaint = error;
// AnimatedSize even with nothing under the row: a complaint ARRIVING is
// the interesting case, and a row that only starts animating once it has
// something to animate would jump on the way in and ease on the way out.
//
// Its the row's own height thats moving, so it grows downward - anchored
// at the top, or every row above the one that was rejected shuffles.
//
// And ALWAYS the column under it, even when theres nothing in it but the
// row. Swapping between `row` and `Column(row, ...)` puts a different
// widget type at that position, so Flutter throws the subtree away and
// builds a new one - which takes the FIELD'S element with it. A brand
// new AnimatedOpacity starts AT its target, so the outline turned up
// already red however long it had been told to take getting there.
return AnimatedSize(
duration: kFieldErrorDuration,
curve: kFieldErrorCurve,
alignment: Alignment.topCenter,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
row,
// sat in the row's own bottom padding rather than under it, so the
// note belongs to the row above it and not to the rule below. Nudged
// up by the same amount it stands off the next one.
if (complaint != null)
Padding(
padding: EdgeInsets.only(
bottom: note == null ? density.gapMd : 0,
),
child: Text(
complaint,
style: TextStyle(
fontSize: density.textXxs,
color: theme.colorScheme.destructive,
),
),
),
if (note != null)
Padding(
padding: EdgeInsets.only(
top: complaint == null ? 0 : density.gapXxs,
bottom: density.gapMd,
),
child: Text(
note,
style: TextStyle(
fontSize: density.textXxs,
color: theme.colorScheme.mutedForeground,
),
),
),
],
),
);
}
}
+490
View File
@@ -0,0 +1,490 @@
import "package:flutter/widgets.dart";
import "package:flutter_lucide/flutter_lucide.dart";
import "package:garage_ui/button.dart";
import "package:garage_ui/theme/garage_theme.dart";
// A sheet that comes up from the bottom edge. The phone's version of a dialog
// for anything thats more than a sentence and two buttons - a list to pick
// from, a handful of per-item controls, a search box with results under it.
//
// Its the same surface an AlertDialog is (card, the section border, dimmed
// page behind), just anchored to the bottom and only rounded on top, because
// thats where a thumb is. Flat like everything else here - no shadow, the
// border and the scrim are what lift it off the page.
//
// On a wide window it doesnt go edge to edge. A 1400px sheet of four rows is
// a banner, not a sheet, so it caps at [maxWidth] and sits centred.
/// Shows a modal sheet and returns whatever it gets popped with.
///
/// [builder] builds the body. The sheet handles its own scrolling, so hand it
/// a Column of rows rather than a ListView - unless the body IS a long list,
/// in which case pass [scrollable] false and bring your own scroller (it gets
/// a bounded height to work in).
Future<T?> showSheet<T>({
required BuildContext context,
required WidgetBuilder builder,
String? title,
String? subtitle,
Widget? trailing,
bool dismissible = true,
bool scrollable = true,
double maxHeightFactor = 0.85,
double maxWidth = 560,
bool useRootNavigator = true,
}) {
final nav = Navigator.of(context, rootNavigator: useRootNavigator);
final themes = InheritedTheme.capture(from: context, to: nav.context);
return nav.push<T>(
_SheetRoute<T>(
themes: themes,
dismissible: dismissible,
builder: (ctx) => Sheet(
title: title,
subtitle: subtitle,
trailing: trailing,
scrollable: scrollable,
maxHeightFactor: maxHeightFactor,
maxWidth: maxWidth,
onClose: dismissible ? () => Navigator.of(ctx).maybePop() : null,
child: Builder(builder: builder),
),
),
);
}
class _SheetRoute<T> extends PopupRoute<T> {
_SheetRoute({
required this.builder,
required this.themes,
required this.dismissible,
});
final WidgetBuilder builder;
final CapturedThemes themes;
final bool dismissible;
// the drag handle moves the route by hand. controller is protected, so the
// sheet asks through here rather than grabbing it
void dragTo(double v) => controller?.value = v.clamp(0.0, 1.0);
void settle() => controller?.animateTo(1, curve: Curves.easeOutCubic);
@override
Color? get barrierColor => const Color(0x00000000);
// the scrim is painted below so it can fade with the drag, the barrier is
// just the thing that catches the tap
@override
bool get barrierDismissible => dismissible;
@override
String? get barrierLabel => "Dismiss";
@override
Duration get transitionDuration => const Duration(milliseconds: 240);
@override
Duration get reverseTransitionDuration => const Duration(milliseconds: 180);
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return themes.wrap(
_SheetDragScope(
route: this,
child: Builder(builder: builder),
),
);
}
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
final curve = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return Stack(
children: [
Positioned.fill(
child: IgnorePointer(
child: FadeTransition(
opacity: curve,
child: ColoredBox(color: const Color(0x99000000)),
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(curve),
child: child,
),
),
],
);
}
}
// lets the handle reach the route to drive it by hand while its being dragged
// down, the same way a real sheet follows your finger rather than waiting for
// you to let go
class _SheetDragScope extends InheritedWidget {
const _SheetDragScope({required this.route, required super.child});
final _SheetRoute<dynamic> route;
static _SheetRoute<dynamic>? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_SheetDragScope>()?.route;
@override
bool updateShouldNotify(_SheetDragScope old) => old.route != route;
}
/// The sheet surface on its own, for when you want it somewhere other than a
/// modal route - pinned under a page, say. [showSheet] wraps one of these.
class Sheet extends StatefulWidget {
const Sheet({
super.key,
required this.child,
this.title,
this.subtitle,
this.trailing,
this.onClose,
this.scrollable = true,
this.maxHeightFactor = 0.85,
this.maxWidth = 560,
});
final Widget child;
final String? title;
final String? subtitle;
/// Far end of the header, before the close button. A count, a single action.
final Widget? trailing;
/// Null hides the close button (and the handle stops dismissing).
final VoidCallback? onClose;
final bool scrollable;
final double maxHeightFactor;
final double maxWidth;
@override
State<Sheet> createState() => _SheetState();
}
class _SheetState extends State<Sheet> {
double _dragged = 0;
double _height = 1;
_SheetRoute<dynamic>? get _route => _SheetDragScope.maybeOf(context);
void _dragUpdate(DragUpdateDetails d) {
if (widget.onClose == null) return;
final route = _route;
if (route == null) return;
_dragged = (_dragged + d.delta.dy).clamp(0.0, _height);
// drive the route's own controller so the scrim fades with the finger
route.dragTo(1 - _dragged / _height);
}
void _dragEnd(DragEndDetails d) {
if (widget.onClose == null) return;
final route = _route;
if (route == null) return;
final v = d.primaryVelocity ?? 0;
final gone = v > 700 || _dragged > _height * 0.35;
_dragged = 0;
if (gone) {
widget.onClose!();
} else {
route.settle();
}
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final cs = theme.colorScheme;
final d = theme.density;
final mq = MediaQuery.of(context);
final radius = Radius.circular(theme.panelRadius + 4);
final header = GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragUpdate: _dragUpdate,
onVerticalDragEnd: _dragEnd,
excludeFromSemantics: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.only(top: d.gapSm, bottom: d.gapXs),
child: Center(
child: Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: cs.mutedForeground.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
),
if (widget.title != null || widget.onClose != null)
Padding(
padding: EdgeInsets.fromLTRB(
d.containerPadding,
0,
d.gapMd,
d.gapSm,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.title != null)
Semantics(
header: true,
child: Text(
widget.title!,
style: theme.typography.semiBold.copyWith(
fontSize: d.textSm,
),
),
),
if (widget.subtitle != null) ...[
SizedBox(height: d.gapXxs),
Text(
widget.subtitle!,
style: TextStyle(
fontSize: d.textXxs,
color: cs.mutedForeground,
),
),
],
],
),
),
if (widget.trailing != null) ...[
SizedBox(width: d.gapSm),
widget.trailing!,
],
if (widget.onClose != null) ...[
SizedBox(width: d.gapXs),
IconButton.ghost(
semanticLabel: "Close",
onPressed: widget.onClose,
icon: const Icon(LucideIcons.x),
),
],
],
),
),
Container(height: 1, color: cs.divider),
],
),
);
// keyboard pushes the whole sheet up, not just the bit with the field in
final maxH = (mq.size.height - mq.viewInsets.bottom - mq.padding.top) *
widget.maxHeightFactor;
Widget body = widget.child;
if (widget.scrollable) {
body = SingleChildScrollView(
padding: EdgeInsets.only(bottom: mq.padding.bottom + d.gapMd),
child: body,
);
} else {
body = Padding(
padding: EdgeInsets.only(bottom: mq.padding.bottom),
child: body,
);
}
return Padding(
padding: EdgeInsets.only(bottom: mq.viewInsets.bottom),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: widget.maxWidth, maxHeight: maxH),
child: LayoutBuilder(
builder: (context, c) {
_height = c.maxHeight;
return Semantics(
scopesRoute: true,
namesRoute: widget.title != null,
label: widget.title,
explicitChildNodes: true,
child: Container(
decoration: BoxDecoration(
color: cs.card,
borderRadius: BorderRadius.vertical(top: radius),
border: Border(
top: BorderSide(color: cs.propertiesSectionBorder),
left: BorderSide(color: cs.propertiesSectionBorder),
right: BorderSide(color: cs.propertiesSectionBorder),
),
),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
header,
// the list takes what the header leaves and no more
widget.scrollable
? Flexible(child: body)
: Expanded(child: body),
],
),
),
);
},
),
),
);
}
}
/// A tappable row for a sheet or any other plain list - icon, a label, an
/// optional muted line under it and something on the end.
///
/// The list row the kit didnt have. A ghost Button stretched full width got
/// close, but it centres a single line and has nowhere for the second one.
class SheetRow extends StatefulWidget {
const SheetRow({
super.key,
required this.title,
this.subtitle,
this.leading,
this.trailing,
this.onPressed,
this.selected = false,
this.destructive = false,
});
final Widget title;
final Widget? subtitle;
final Widget? leading;
final Widget? trailing;
final VoidCallback? onPressed;
/// Tinted like the rail marks the page youre on.
final bool selected;
/// Title and icon in the destructive colour.
final bool destructive;
@override
State<SheetRow> createState() => _SheetRowState();
}
class _SheetRowState extends State<SheetRow> {
bool _hover = false;
bool _down = false;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final cs = theme.colorScheme;
final d = theme.density;
final enabled = widget.onPressed != null;
Color? fill;
if (widget.selected) fill = cs.secondary;
if (enabled && (_hover || _down)) fill = cs.rowHovered;
final fg = widget.destructive ? cs.destructive : cs.foreground;
Widget row = Container(
constraints: BoxConstraints(minHeight: d.controlHeight + d.gapSm),
padding: EdgeInsets.symmetric(
horizontal: d.containerPadding,
vertical: d.gapSm,
),
color: fill,
child: Row(
children: [
if (widget.leading != null) ...[
IconTheme.merge(
data: IconThemeData(
color: widget.destructive ? cs.destructive : cs.mutedForeground,
),
child: widget.leading!,
),
SizedBox(width: d.gapMd),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
DefaultTextStyle.merge(
style: TextStyle(color: fg),
child: widget.title,
),
if (widget.subtitle != null) ...[
SizedBox(height: d.gapXxs),
DefaultTextStyle.merge(
style: TextStyle(
fontSize: d.textXxs,
color: cs.mutedForeground,
),
child: widget.subtitle!,
),
],
],
),
),
if (widget.trailing != null) ...[
SizedBox(width: d.gapSm),
IconTheme.merge(
data: IconThemeData(color: cs.mutedForeground),
child: widget.trailing!,
),
],
],
),
);
if (!enabled) return row;
return Semantics(
button: true,
selected: widget.selected,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => setState(() => _down = true),
onTapCancel: () => setState(() => _down = false),
onTapUp: (_) => setState(() => _down = false),
onTap: widget.onPressed,
child: row,
),
),
);
}
}
+907
View File
@@ -0,0 +1,907 @@
// GarageUI — surfaces + basic layout widgets.
//
// hand rolled replacements for the shadcn surface bits we lean on:
// OutlinedContainer, Card, SurfaceCard, IconContainer, Divider,
// VerticalDivider, Gap, Basic and Label.
//
// styling here is copied straight out of shadcn_flutter 0.0.52 so the app
// keeps looking exactly the same. we still read the theme + a couple of tiny
// pure helpers (styleValue, subtractByBorder, scaleAlpha, the density padding
// resolver and the .small()/.muted() text modifiers) from shadcn during the
// migration — those imports get repointed later.
import "package:flutter/rendering.dart";
import "package:flutter/widgets.dart";
// hide the classes we redefine so our own definitions win. everything else
// (Theme, styleValue, the text extensions, SurfaceBlur, density helpers...)
// still comes through unprefixed.
import "package:garage_ui/theme/garage_theme.dart";
import "package:garage_ui/theme/support.dart";
// ---------------------------------------------------------------------------
// Gap
// ---------------------------------------------------------------------------
/// A widget that takes a fixed amount of space in the direction of its parent.
///
/// Only works as a descendant of a [Row], [Column] or [Flex] (or a
/// [Scrollable]). Reproduced from the `gap` package so we don't rely on it
/// coming in transitively through shadcn.
/// A step on the theme's gap scale. See [Density] for the values.
enum GapStep {
xxs,
xs,
sm,
md,
lg,
xl,
xxl;
double of(Density density) => switch (this) {
GapStep.xxs => density.gapXxs,
GapStep.xs => density.gapXs,
GapStep.sm => density.gapSm,
GapStep.md => density.gapMd,
GapStep.lg => density.gapLg,
GapStep.xl => density.gapXl,
GapStep.xxl => density.gapXxl,
};
}
class Gap extends StatelessWidget {
const Gap(
double this.mainAxisExtent, {
super.key,
this.crossAxisExtent,
this.color,
}) : step = null,
assert(mainAxisExtent >= 0 && mainAxisExtent < double.infinity),
assert(crossAxisExtent == null || crossAxisExtent >= 0);
// The scale constructors. These resolve their extent at build time, which is
// the whole point - a call site stays `const` and still moves when the
// density does. Handing `Gap` a number from `theme.density` at the call site
// would work too, but it costs the const and drags a GarageTheme.of() into
// every build method that happens to contain a gap.
const Gap.xxs({super.key, this.crossAxisExtent, this.color})
: mainAxisExtent = null,
step = GapStep.xxs;
const Gap.xs({super.key, this.crossAxisExtent, this.color})
: mainAxisExtent = null,
step = GapStep.xs;
const Gap.sm({super.key, this.crossAxisExtent, this.color})
: mainAxisExtent = null,
step = GapStep.sm;
const Gap.md({super.key, this.crossAxisExtent, this.color})
: mainAxisExtent = null,
step = GapStep.md;
const Gap.lg({super.key, this.crossAxisExtent, this.color})
: mainAxisExtent = null,
step = GapStep.lg;
const Gap.xl({super.key, this.crossAxisExtent, this.color})
: mainAxisExtent = null,
step = GapStep.xl;
const Gap.xxl({super.key, this.crossAxisExtent, this.color})
: mainAxisExtent = null,
step = GapStep.xxl;
const Gap.expand(double mainAxisExtent, {Key? key, Color? color})
: this(
mainAxisExtent,
key: key,
crossAxisExtent: double.infinity,
color: color,
);
/// space taken along the parent's main axis. null when [step] is set, in
/// which case the extent comes off the theme instead.
final double? mainAxisExtent;
/// which step of the density's gap scale to use. null for a literal [Gap].
final GapStep? step;
/// space taken along the cross axis. null = match the parent constraints.
final double? crossAxisExtent;
/// optional fill colour.
final Color? color;
@override
Widget build(BuildContext context) {
final scrollableState = Scrollable.maybeOf(context);
final AxisDirection? axisDirection = scrollableState?.axisDirection;
final Axis? fallbackDirection = axisDirection == null
? null
: axisDirectionToAxis(axisDirection);
final extent = mainAxisExtent ?? step!.of(GarageTheme.of(context).density);
return _RawGap(
extent,
crossAxisExtent: crossAxisExtent,
color: color,
fallbackDirection: fallbackDirection,
);
}
}
class _RawGap extends LeafRenderObjectWidget {
const _RawGap(
this.mainAxisExtent, {
this.crossAxisExtent,
this.color,
this.fallbackDirection,
}) : assert(mainAxisExtent >= 0 && mainAxisExtent < double.infinity),
assert(crossAxisExtent == null || crossAxisExtent >= 0);
final double mainAxisExtent;
final double? crossAxisExtent;
final Color? color;
final Axis? fallbackDirection;
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderGap(
mainAxisExtent: mainAxisExtent,
crossAxisExtent: crossAxisExtent ?? 0,
color: color,
fallbackDirection: fallbackDirection,
);
}
@override
void updateRenderObject(BuildContext context, _RenderGap renderObject) {
renderObject
..mainAxisExtent = mainAxisExtent
..crossAxisExtent = crossAxisExtent ?? 0
..color = color
..fallbackDirection = fallbackDirection;
}
}
class _RenderGap extends RenderBox {
_RenderGap({
required double mainAxisExtent,
double? crossAxisExtent,
Axis? fallbackDirection,
Color? color,
}) : _mainAxisExtent = mainAxisExtent,
_crossAxisExtent = crossAxisExtent,
_color = color,
_fallbackDirection = fallbackDirection;
double get mainAxisExtent => _mainAxisExtent;
double _mainAxisExtent;
set mainAxisExtent(double value) {
if (_mainAxisExtent != value) {
_mainAxisExtent = value;
markNeedsLayout();
}
}
double? get crossAxisExtent => _crossAxisExtent;
double? _crossAxisExtent;
set crossAxisExtent(double? value) {
if (_crossAxisExtent != value) {
_crossAxisExtent = value;
markNeedsLayout();
}
}
Axis? get fallbackDirection => _fallbackDirection;
Axis? _fallbackDirection;
set fallbackDirection(Axis? value) {
if (_fallbackDirection != value) {
_fallbackDirection = value;
markNeedsLayout();
}
}
Axis? get _direction {
final parentNode = parent;
if (parentNode is RenderFlex) {
return parentNode.direction;
} else {
return fallbackDirection;
}
}
Color? get color => _color;
Color? _color;
set color(Color? value) {
if (_color != value) {
_color = value;
markNeedsPaint();
}
}
@override
double computeMinIntrinsicWidth(double height) {
return _computeIntrinsicExtent(
Axis.horizontal,
() => super.computeMinIntrinsicWidth(height),
)!;
}
@override
double computeMaxIntrinsicWidth(double height) {
return _computeIntrinsicExtent(
Axis.horizontal,
() => super.computeMaxIntrinsicWidth(height),
)!;
}
@override
double computeMinIntrinsicHeight(double width) {
return _computeIntrinsicExtent(
Axis.vertical,
() => super.computeMinIntrinsicHeight(width),
)!;
}
@override
double computeMaxIntrinsicHeight(double width) {
return _computeIntrinsicExtent(
Axis.vertical,
() => super.computeMaxIntrinsicHeight(width),
)!;
}
double? _computeIntrinsicExtent(Axis axis, double Function() compute) {
final Axis? direction = _direction;
if (direction == axis) {
return _mainAxisExtent;
} else {
if (_crossAxisExtent!.isFinite) {
return _crossAxisExtent;
} else {
return compute();
}
}
}
@override
Size computeDryLayout(BoxConstraints constraints) {
final Axis? direction = _direction;
if (direction != null) {
if (direction == Axis.horizontal) {
return constraints.constrain(Size(mainAxisExtent, crossAxisExtent!));
} else {
return constraints.constrain(Size(crossAxisExtent!, mainAxisExtent));
}
}
// No Flex parent and no scrollable to borrow a direction from. This used
// to throw, which took the whole enclosing subtree down at layout time
// with nothing to catch it beforehand - a Gap inside an AnimatedSize was
// enough to destroy a page. A vertical gap is the overwhelmingly common
// intent, so fall back to it and complain in debug instead of exploding
// in release.
assert(() {
FlutterError.reportError(
FlutterErrorDetails(
exception: FlutterError(
"Gap has no axis to size along.\n"
"It isn't a direct child of a Flex (Row/Column) and there's no "
"Scrollable above it, so it fell back to a vertical gap of "
"$mainAxisExtent. Give it a Flex parent, or use SizedBox if you "
"meant a fixed box.",
),
library: "garage_ui",
),
);
return true;
}());
return constraints.constrain(Size(crossAxisExtent ?? 0, mainAxisExtent));
}
@override
void performLayout() {
size = computeDryLayout(constraints);
}
@override
void paint(PaintingContext context, Offset offset) {
if (color != null) {
final Paint paint = Paint()..color = color!;
context.canvas.drawRect(offset & size, paint);
}
}
}
// ---------------------------------------------------------------------------
// OutlinedContainer
// ---------------------------------------------------------------------------
/// A container with a border + background and optional surface blur.
///
/// This is the workhorse surface the toolbar/hud/panels sit on. Defaults come
/// from the theme: xl border radius, [ColorScheme.background] fill and
/// [ColourScheme.controlBorder] border at 1px (scaled).
class OutlinedContainer extends StatefulWidget {
const OutlinedContainer({
super.key,
required this.child,
this.borderColor,
this.backgroundColor,
this.clipBehavior = Clip.antiAlias,
this.borderRadius,
this.borderStyle,
this.borderWidth,
this.boxShadow,
this.padding,
this.surfaceOpacity,
this.surfaceBlur,
this.width,
this.height,
this.duration,
});
final Widget child;
final Color? backgroundColor;
final Color? borderColor;
final Clip clipBehavior;
final BorderRadiusGeometry? borderRadius;
final BorderStyle? borderStyle;
final double? borderWidth;
final List<BoxShadow>? boxShadow;
final EdgeInsetsGeometry? padding;
final double? surfaceOpacity;
final double? surfaceBlur;
final double? width;
final double? height;
final Duration? duration;
@override
State<OutlinedContainer> createState() => _OutlinedContainerState();
}
class _OutlinedContainerState extends State<OutlinedContainer> {
final GlobalKey _mainContainerKey = GlobalKey();
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scaling = theme.scaling;
var borderRadius = (widget.borderRadius ?? theme.borderRadiusXl).resolve(
Directionality.of(context),
);
var backgroundColor =
widget.backgroundColor ?? theme.colorScheme.background;
final surfaceOpacity = widget.surfaceOpacity;
if (surfaceOpacity != null) {
backgroundColor = backgroundColor.scaleAlpha(surfaceOpacity);
}
// controlBorder, not muted. muted is a FILL - it was doing stroke duty
// here only because it happened to be the darkest thing on offer, and
// it derives BELOW the ground, so the outline on the workhorse surface
// was reading as a shadow rather than an edge.
final borderColor = widget.borderColor ?? theme.colorScheme.controlBorder;
final borderWidth = widget.borderWidth ?? (1 * scaling);
final borderStyle = widget.borderStyle ?? BorderStyle.solid;
final boxShadow = widget.boxShadow ?? const <BoxShadow>[];
final padding = widget.padding ?? EdgeInsets.zero;
final surfaceBlur = widget.surfaceBlur;
Widget childWidget = AnimatedContainer(
duration: widget.duration ?? Duration.zero,
key: _mainContainerKey,
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
color: backgroundColor,
border: Border.all(
color: borderColor,
width: borderWidth,
style: borderStyle,
),
borderRadius: borderRadius,
boxShadow: boxShadow,
),
child: AnimatedContainer(
duration: widget.duration ?? Duration.zero,
clipBehavior: widget.clipBehavior,
decoration: BoxDecoration(
borderRadius: subtractByBorder(borderRadius, borderWidth),
),
child: DensityContainerPadding(padding: padding, child: widget.child),
),
);
if (surfaceBlur != null && surfaceBlur > 0) {
childWidget = SurfaceBlur(
surfaceBlur: surfaceBlur,
borderRadius: subtractByBorder(borderRadius, borderWidth),
child: childWidget,
);
}
return childWidget;
}
}
// ---------------------------------------------------------------------------
// Card / SurfaceCard
// ---------------------------------------------------------------------------
/// A card surface — basically an [OutlinedContainer] with card colours and a
/// density aware padding. Merges a [ColourScheme.foreground] default text
/// colour over its child.
class Card extends StatelessWidget {
const Card({
super.key,
required this.child,
this.padding,
this.filled,
this.fillColor,
this.borderRadius,
this.clipBehavior,
this.borderColor,
this.borderWidth,
this.boxShadow,
this.surfaceOpacity,
this.surfaceBlur,
this.duration,
});
final Widget child;
final EdgeInsetsGeometry? padding;
final bool? filled;
final Color? fillColor;
final BorderRadiusGeometry? borderRadius;
final Color? borderColor;
final double? borderWidth;
final Clip? clipBehavior;
final List<BoxShadow>? boxShadow;
final double? surfaceOpacity;
final double? surfaceBlur;
final Duration? duration;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final densityContainerPadding = theme.density.containerPadding;
final padding = this.padding ?? EdgeInsets.all(densityContainerPadding);
final filled = this.filled ?? false;
final fillColor = this.fillColor ?? theme.colorScheme.border;
final clipBehavior = this.clipBehavior ?? Clip.none;
return OutlinedContainer(
clipBehavior: clipBehavior,
borderRadius: borderRadius,
borderWidth: borderWidth,
borderColor: borderColor,
backgroundColor: filled ? fillColor : theme.colorScheme.card,
boxShadow: boxShadow,
padding: padding,
surfaceOpacity: surfaceOpacity,
surfaceBlur: surfaceBlur,
duration: duration,
child: DefaultTextStyle.merge(
style: TextStyle(color: theme.colorScheme.foreground),
child: child,
),
);
}
}
/// [Card] variant that picks up the theme's surface blur/opacity. When drawn
/// inside a sheet overlay it collapses to just padding (no double surface).
class SurfaceCard extends StatelessWidget {
const SurfaceCard({
super.key,
required this.child,
this.padding,
this.filled,
this.fillColor,
this.borderRadius,
this.clipBehavior,
this.borderColor,
this.borderWidth,
this.boxShadow,
this.surfaceOpacity,
this.surfaceBlur,
this.duration,
});
final Widget child;
final EdgeInsetsGeometry? padding;
final bool? filled;
final Color? fillColor;
final BorderRadiusGeometry? borderRadius;
final Color? borderColor;
final double? borderWidth;
final Clip? clipBehavior;
final List<BoxShadow>? boxShadow;
final double? surfaceOpacity;
final double? surfaceBlur;
final Duration? duration;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final isSheetOverlay = SheetOverlayHandler.isSheetOverlay(context);
final densityContainerPadding = theme.density.containerPadding;
if (isSheetOverlay) {
final padding = this.padding ?? EdgeInsets.all(densityContainerPadding);
return Padding(padding: padding, child: child);
}
return Card(
clipBehavior: clipBehavior,
borderRadius: borderRadius,
borderWidth: borderWidth,
borderColor: borderColor,
filled: filled,
fillColor: fillColor,
boxShadow: boxShadow,
padding: padding,
surfaceOpacity: surfaceOpacity ?? theme.surfaceOpacity,
surfaceBlur: surfaceBlur ?? theme.surfaceBlur,
duration: duration,
child: child,
);
}
}
// ---------------------------------------------------------------------------
// IconContainer
// ---------------------------------------------------------------------------
/// A little rounded chip around an icon. Defaults to the primary colour with
/// a primaryForeground tint on the icon.
class IconContainer extends StatelessWidget {
const IconContainer({
super.key,
required this.icon,
this.padding,
this.borderRadius,
this.backgroundColor,
this.iconColor,
});
final Widget icon;
final EdgeInsetsGeometry? padding;
final BorderRadius? borderRadius;
final Color? backgroundColor;
final Color? iconColor;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
// padXs used to be a quarter-ish of the container base, resolved by density.
final xs = theme.density.containerPadding * 0.25;
return Container(
padding: padding ?? EdgeInsets.all(xs),
decoration: BoxDecoration(
color: backgroundColor ?? theme.colorScheme.primary,
borderRadius: borderRadius ?? theme.borderRadiusMd,
),
child: IconTheme(
data: IconThemeData(
color: iconColor ?? theme.colorScheme.primaryForeground,
),
child: icon,
),
);
}
}
// ---------------------------------------------------------------------------
// Divider / VerticalDivider
// ---------------------------------------------------------------------------
/// A thin horizontal rule. Colour defaults to the scheme divider, 1px thick.
///
/// note: shadcn's Divider can host a centered child label — nothing in the app
/// uses that so it's dropped here.
class Divider extends StatelessWidget implements PreferredSizeWidget {
const Divider({
super.key,
this.color,
this.height,
this.thickness,
this.indent,
this.endIndent,
});
final Color? color;
final double? height;
final double? thickness;
final double? indent;
final double? endIndent;
@override
Size get preferredSize => Size(0, height ?? 1);
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final color = this.color ?? theme.colorScheme.divider;
final thickness = this.thickness ?? 1.0;
final height = this.height ?? thickness;
final indent = this.indent ?? 0.0;
final endIndent = this.endIndent ?? 0.0;
return SizedBox(
height: height,
width: double.infinity,
child: CustomPaint(
painter: _DividerPainter(
color: color,
thickness: thickness,
indent: indent,
endIndent: endIndent,
),
),
);
}
}
/// vertical counterpart of [Divider].
class VerticalDivider extends StatelessWidget implements PreferredSizeWidget {
const VerticalDivider({
super.key,
this.color,
this.width,
this.thickness,
this.indent,
this.endIndent,
});
final Color? color;
final double? width;
final double? thickness;
final double? indent;
final double? endIndent;
@override
Size get preferredSize => Size(width ?? 1, 0);
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
return SizedBox(
width: width ?? 1,
height: double.infinity,
child: CustomPaint(
painter: _VerticalDividerPainter(
color: color ?? theme.colorScheme.divider,
thickness: thickness ?? 1,
indent: indent ?? 0,
endIndent: endIndent ?? 0,
),
),
);
}
}
class _DividerPainter extends CustomPainter {
_DividerPainter({
required this.color,
required this.thickness,
required this.indent,
required this.endIndent,
});
final Color color;
final double thickness;
final double indent;
final double endIndent;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = thickness
..strokeCap = StrokeCap.square;
final start = Offset(indent, size.height / 2);
final end = Offset(size.width - endIndent, size.height / 2);
canvas.drawLine(start, end, paint);
}
@override
bool shouldRepaint(covariant _DividerPainter oldDelegate) {
return oldDelegate.color != color ||
oldDelegate.thickness != thickness ||
oldDelegate.indent != indent ||
oldDelegate.endIndent != endIndent;
}
}
class _VerticalDividerPainter extends CustomPainter {
_VerticalDividerPainter({
required this.color,
required this.thickness,
required this.indent,
required this.endIndent,
});
final Color color;
final double thickness;
final double indent;
final double endIndent;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = thickness
..strokeCap = StrokeCap.square;
final start = Offset(size.width / 2, indent);
final end = Offset(size.width / 2, size.height - endIndent);
canvas.drawLine(start, end, paint);
}
@override
bool shouldRepaint(covariant _VerticalDividerPainter oldDelegate) {
return oldDelegate.color != color ||
oldDelegate.thickness != thickness ||
oldDelegate.indent != indent ||
oldDelegate.endIndent != endIndent;
}
}
// ---------------------------------------------------------------------------
// Basic
// ---------------------------------------------------------------------------
/// The classic list-item style layout: optional leading, a title/subtitle/
/// content column, and an optional trailing. Title gets small+medium, subtitle
/// gets xSmall+muted, content gets small — matching shadcn exactly.
class Basic extends StatelessWidget {
const Basic({
super.key,
this.leading,
this.title,
this.subtitle,
this.content,
this.trailing,
this.leadingAlignment,
this.trailingAlignment,
this.titleAlignment,
this.subtitleAlignment,
this.contentAlignment,
this.contentSpacing, // 16
this.titleSpacing, // 4
this.mainAxisAlignment,
this.padding,
});
final Widget? leading;
final Widget? title;
final Widget? subtitle;
final Widget? content;
final Widget? trailing;
final AlignmentGeometry? leadingAlignment;
final AlignmentGeometry? trailingAlignment;
final AlignmentGeometry? titleAlignment;
final AlignmentGeometry? subtitleAlignment;
final AlignmentGeometry? contentAlignment;
final double? contentSpacing;
final double? titleSpacing;
final MainAxisAlignment? mainAxisAlignment;
final EdgeInsetsGeometry? padding;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final densityGap = theme.density.containerGap;
final densityContainerPadding = theme.density.containerPadding;
final padding = this.padding ?? EdgeInsets.zero;
final resolvedPadding = resolveEdgeInsets(padding, densityContainerPadding);
final contentSpacing = this.contentSpacing ?? densityGap * 2;
final titleSpacing = this.titleSpacing ?? densityGap * 0.5;
final leadingAlignment = this.leadingAlignment ?? Alignment.topCenter;
final trailingAlignment = this.trailingAlignment ?? Alignment.topCenter;
final titleAlignment = this.titleAlignment ?? Alignment.topLeft;
final subtitleAlignment = this.subtitleAlignment ?? Alignment.topLeft;
final contentAlignment = this.contentAlignment ?? Alignment.topLeft;
final mainAxisAlignment =
this.mainAxisAlignment ?? MainAxisAlignment.center;
return Padding(
padding: resolvedPadding,
child: IntrinsicWidth(
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: mainAxisAlignment,
children: [
if (leading != null)
Align(alignment: leadingAlignment, child: leading!),
if (leading != null &&
(title != null || content != null || subtitle != null))
SizedBox(width: contentSpacing),
if (title != null || content != null || subtitle != null)
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: mainAxisAlignment,
children: [
if (title != null)
Align(
alignment: titleAlignment,
child: title!,
).small().medium(),
if (title != null && subtitle != null)
SizedBox(height: densityGap * 0.25),
if (subtitle != null)
Align(
alignment: subtitleAlignment,
child: subtitle!,
).xSmall().muted(),
if ((title != null || subtitle != null) &&
content != null)
SizedBox(height: titleSpacing),
if (content != null)
Align(
alignment: contentAlignment,
child: content!,
).small(),
],
),
),
if (trailing != null &&
(title != null ||
content != null ||
leading != null ||
subtitle != null))
SizedBox(width: contentSpacing),
if (trailing != null)
Align(alignment: trailingAlignment, child: trailing!),
],
),
),
),
);
}
}
// ---------------------------------------------------------------------------
// Label
// ---------------------------------------------------------------------------
/// A label row — the main child expanded in the middle with optional leading
/// and trailing widgets, 8px (scaled) gaps between them.
class Label extends StatelessWidget {
const Label({super.key, this.leading, required this.child, this.trailing});
final Widget? leading;
final Widget child;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scaling = theme.scaling;
return IntrinsicWidth(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (leading != null) leading!,
if (leading != null) SizedBox(width: 8 * scaling),
Expanded(child: child),
if (trailing != null) SizedBox(width: 8 * scaling),
if (trailing != null) trailing!,
],
),
);
}
}
+156
View File
@@ -0,0 +1,156 @@
import "package:flutter/widgets.dart";
import "package:flutter_lucide/flutter_lucide.dart";
import "button.dart";
import "editor_chrome.dart";
import "surface.dart";
import "theme/garage_theme.dart";
/// One entry in a [TabView] - the label (and optional icon) for its header
/// button, plus the widget shown while it's selected.
class TabViewItem {
const TabViewItem({required this.label, this.icon, required this.child});
final String label;
final IconData? icon;
final Widget child;
}
/// A row of buttons across the top, one page shown below at a time.
///
/// Not a browser-style tab strip - no close buttons, no reordering, no
/// scrolling overflow. This is closer to what FL Studio calls its pattern/
/// mixer/playlist "windows": conceptually separate workspaces that happen
/// to live as pages in the same frame instead of actual floating windows.
///
/// Every tab's widget stays mounted the whole time (via [IndexedStack]),
/// just hidden when it isn't selected, so switching tabs doesn't reset
/// whatever state the page underneath is holding onto - scroll position,
/// text fields, a half-finished drag, etc.
///
/// Controlled, not stateful - the caller owns [selectedIndex] and finds
/// out about taps via [onSelected], same shape as everything else in
/// GarageUI with a "which one is picked" concept (see Select).
class TabView extends StatelessWidget {
const TabView({
super.key,
required this.tabs,
required this.selectedIndex,
required this.onSelected,
this.onClosed,
});
final List<TabViewItem> tabs;
final int selectedIndex;
final ValueChanged<int> onSelected;
/// Called with a tab's index when its close (x) button is pressed - only
/// the active tab gets one. Leave null and no close button shows at all,
/// same "nothing happens unless the caller wired it up" deal as everywhere
/// else here - TabView itself doesn't know what "closing" a tab even means
/// for the caller (remove it? just hide it? ask first?), it just reports it
final ValueChanged<int>? onClosed;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(context),
Expanded(
child: IndexedStack(
index: selectedIndex,
children: [for (final tab in tabs) tab.child],
),
),
],
);
}
Widget _header(BuildContext context) {
final theme = GarageTheme.of(context);
// tried wrapping ChromeBar in an outer bordered DecoratedBox for the
// divider first - didnt work, ChromeBar paints its own opaque chrome-
// coloured background as a child ON TOP of that outer decoration (same
// bounds and everything) so the border stroke just gets painted over
// and never shows. a real 1px sibling below it actually renders
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ChromeBar(
padding: const EdgeInsets.fromLTRB(8, 4, 6, 4),
child: Row(
children: [
for (var i = 0; i < tabs.length; i++) ...[
if (i > 0) const Gap(4),
_tabButton(context, i),
],
],
),
),
Container(height: 1, color: theme.colorScheme.divider),
],
);
}
Widget _tabButton(BuildContext context, int index) {
final theme = GarageTheme.of(context);
final tab = tabs[index];
final selected = index == selectedIndex;
// Button has no automatic icon theming of its own (it never wraps its
// content in an IconTheme) - has to be coloured by hand here to match
// whichever variant its sitting on, or it just comes out whatever the
// ambient default happens to be, not the button's actual foreground.
// ghost's foreground is just colorScheme.foreground, not a "ghost"
// specific token - see _buttonGhostIconTheme
final iconColor = selected
? theme.colorScheme.primaryForeground
: theme.colorScheme.foreground;
final leading = tab.icon == null
? null
: Icon(tab.icon, size: theme.iconTheme.small.size, color: iconColor);
// compact density, not the default - this header bar is the same
// slim ChromeBar height as the app's own menu bar (which explicitly
// opts into ControlDensity.compact for the same reason), a normal-
// density button doesn't fit in that height without getting squished.
// active tab reads as primary, everything else fades into ghost so it
// doesnt fight the active one for attention
final tabButton = selected
? Button.primary(
style: const ButtonStyle.primary(density: ControlDensity.compact),
leading: leading,
onPressed: () => onSelected(index),
child: Text(tab.label),
)
: Button.ghost(
style: const ButtonStyle.ghost(density: ControlDensity.compact),
leading: leading,
onPressed: () => onSelected(index),
child: Text(tab.label),
);
// close button only shows up on the active tab, and only if the caller
// actually wants one - grouped onto the tab button via ButtonGroup so
// the two share one pill shape instead of looking like two seperate
// buttons bolted together
if (!selected || onClosed == null) return tabButton;
return ButtonGroup.horizontal(
children: [
tabButton,
IconButton.secondary(
// same deal as the tab buttons above - Button never themes its
// child's colour on its own, icon widget has to bring its own
icon: Icon(
LucideIcons.x,
size: theme.iconTheme.small.size,
color: theme.colorScheme.secondaryForeground,
),
density: ControlDensity.compact,
onPressed: () => onClosed!(index),
),
],
);
}
}
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
export "theme/garage_theme.dart";
export "theme/theme_data.dart";
export "theme/colour_scheme.dart";
export "theme/typography.dart";
export "theme/support.dart";
export "theme/schemes.dart";
+562
View File
@@ -0,0 +1,562 @@
import "dart:math" as math;
import "dart:ui" show Color, lerpDouble;
import "package:flutter/painting.dart" show HSLColor;
import "package:flutter/widgets.dart" show Brightness;
// The apps own colour scheme. Used to be a subclass of shadcns ColorScheme which
// meant it kept getting flattened back to a plain base scheme every frame by the
// theme lerp - now its just our own first class type, nothing above it.
//
// Holds the usual semantic slots (background/foreground/primary/...) plus the
// app specific ones (chrome, panel borders, the control family) as equals.
//
// ── On the names ──────────────────────────────────────────────────────────
// The control family used to be called input*. That was a lie measured
// against its own call sites: of the eighteen reads of `inputBorder` exactly
// one was a text field, and the rest were outline buttons, selects, date
// inputs, checkboxes, radios, the colour swatch and toast. Anything with a
// stroke around it took the "input" token because that was the only stroke
// on offer. They're control* now, which is what they always were.
//
// `surfaceSunken` has the same history from the other end: it was
// explorerRowEven, a zebra stripe, and every consumer bar one was using it as
// "one step below the ground" for a whole pane. It's a rung on the ladder,
// not a row colour, so it gets a rung's name.
class ColourScheme {
const ColourScheme({
required this.brightness,
required this.background,
required this.foreground,
required this.card,
required this.popover,
required this.popoverBorder,
required this.tooltipBackground,
required this.tooltipBorder,
required this.primary,
required this.primaryHovered,
required this.primaryForeground,
required this.secondary,
required this.secondaryHovered,
required this.secondaryForeground,
required this.muted,
required this.mutedForeground,
required this.destructive,
required this.border,
required this.divider,
required this.surfaceSunken,
required this.controlFill,
required this.controlFillHovered,
required this.controlFillFocused,
required this.controlBorder,
required this.switchTrackInactive,
required this.ring,
required this.chart1,
required this.chart2,
required this.chart3,
required this.chart4,
required this.chart5,
required this.chrome,
required this.panelBorder,
required this.panelBorderHighlighted,
required this.rowHovered,
required this.rowText,
required this.popoverItemHovered,
required this.propertiesSectionBorder,
});
/// Builds a complete scheme from the handful of colours an app actually
/// chooses, deriving the rest.
///
/// ── How the derivation works ──────────────────────────────────────────
///
/// This used to be a table of HSL lightness offsets. HSL lightness is not
/// perceptually uniform and, worse, an offset that runs off the end of the
/// scale was *reflected*: it kept its size and lost only its direction. On
/// a ground already near the floor that turned "recess this by twelve" into
/// "raise it by twelve", and A&As carbon ended up with a canvas backdrop
/// LIGHTER than the paper sat in front of it.
///
/// Measuring the ten hand authored schemes in CIE L* showed what they
/// actually have in common, and its four rules rather than one table:
///
/// 1. Raised rungs are an absolute L* step off the ground. Zinc and carbon
/// agree here to within 1-3 points despite sitting sixteen points apart,
/// so this part was never the problem.
///
/// 2. Recessed rungs are the same idea until the ground runs out of room
/// underneath, and then they all squeeze by the same factor. Zinc has
/// 19.9 L* of basement and spends 12.6 of it on chrome. Carbon has 3.6
/// and spends all of it. Squeezing keeps their ORDER, which is the thing
/// that actually matters - reflecting destroyed it.
///
/// 3. A stroke is measured against the surface it outlines, not against the
/// ground. Every scheme puts its popover border about ten points over
/// its popover; none of them puts it ten points over the background.
/// (This is the fix that landed for propertiesSectionBorder alone in
/// 38a206f, generalised - it was never a one slot problem.)
///
/// 4. Text is a fraction of the background->foreground span, not an
/// absolute shift. mutedForeground is 60.7% of the way in zinc and
/// 60.7% in carbon, to one decimal, on spans of 71 and 92 points.
///
/// Anything you want to pin exactly is still an argument - every derived
/// slot has an override. Hand authored schemes keep using the const
/// constructor and are untouched.
factory ColourScheme.derive({
required Brightness brightness,
required Color background,
required Color foreground,
required Color primary,
Color? primaryForeground,
Color? destructive,
Color? ring,
// --- escape hatches: pin any derived slot ---
Color? chrome,
Color? card,
Color? popover,
Color? muted,
Color? tooltipBackground,
Color? secondary,
Color? border,
Color? surfaceSunken,
Color? controlFill,
Color? controlFillHovered,
Color? controlFillFocused,
Color? controlBorder,
Color? switchTrackInactive,
Color? mutedForeground,
Color? rowText,
Color? chart1,
Color? chart2,
Color? chart3,
Color? chart4,
Color? chart5,
}) {
final groundL = _lstar(background);
final span = _lstar(foreground) - groundL;
// How much of what the recessed rungs WANT this ground can actually give
// them. chrome is the deepest slot the shared scheme has (the canvas
// backdrop goes deeper, but thats an app side slot now), so it sets the
// scale: if theres room for it nothing squeezes at all.
final basement = brightness == Brightness.dark
? groundL
: math.max(groundL, _deepestSink);
final squeeze = basement >= _deepestSink ? 1.0 : basement / _deepestSink;
Color raise(double points) => _atLstar(background, groundL + points);
Color sink(double points) =>
_atLstar(background, groundL - points * squeeze);
// a stroke sits N points off the surface it outlines. reflects if theres
// no headroom that way - a border that cant get lighter than its own fill
// gets darker by the same amount, which is what youd have picked anyway.
Color edge(Color surface, double points) {
final l = _lstar(surface);
final wanted = l + points;
return _atLstar(
surface,
(wanted > 100 || wanted < 0) ? l - points : wanted,
);
}
Color text(double fraction) =>
_atLstar(foreground, groundL + span * fraction);
final resolvedChrome = chrome ?? sink(12.6);
final resolvedCard = card ?? raise(5.9);
final resolvedPopover = popover ?? sink(11.6);
final resolvedMuted = muted ?? sink(9.1);
final resolvedFill = controlFill ?? sink(9.1);
final resolvedTooltip = tooltipBackground ?? sink(9.1);
return ColourScheme(
brightness: brightness,
background: background,
foreground: foreground,
card: resolvedCard,
popover: resolvedPopover,
popoverBorder: edge(resolvedPopover, 9.9),
tooltipBackground: resolvedTooltip,
tooltipBorder: edge(resolvedTooltip, 9.1),
primary: primary,
// +11 L*, CLAMPED rather than reflected. A stroke that cant get lighter
// than its surface has to go the other way or it disappears, but a hover
// has no such problem - it just wants to be brighter, and a near white
// primary should hover to white, not turn round and dim to grey.
primaryHovered: _atLstar(primary, math.min(100, _lstar(primary) + 11.0)),
primaryForeground: primaryForeground ?? _contrastColor(primary),
secondary: secondary ?? raise(16.3),
secondaryHovered: raise(23.9),
// nine of the ten hand authored schemes put this exactly on foreground.
secondaryForeground: foreground,
muted: resolvedMuted,
mutedForeground: mutedForeground ?? text(0.607),
destructive:
destructive ??
(brightness == Brightness.dark
? const Color(0xffa9575f)
: const Color(0xffb3474f)),
// both reference schemes put the outermost line on the floor with
// chrome - its the gutter between panels, not a stroke on a surface.
border: border ?? resolvedChrome,
// 4.1 points off the ground is a line you have to go looking for. The
// shell has drawn its own rules with panelBorder (9.6) since forever
// and nobody has ever called those loud, so a divider sits just under
// that - visible, still subordinate to the edge of a panel.
divider: edge(background, 9.0),
surfaceSunken: surfaceSunken ?? sink(3.8),
controlFill: resolvedFill,
controlFillHovered: controlFillHovered ?? sink(6.5),
controlFillFocused: controlFillFocused ?? sink(11.0),
controlBorder: controlBorder ?? edge(resolvedFill, 11.8),
// the off state track of a Switch. was called `input`, which told you
// nothing and collided with muted in every derived scheme.
switchTrackInactive: switchTrackInactive ?? resolvedMuted,
ring: ring ?? _brightenForSelectionRing(primary),
chart1: chart1 ?? const Color(0xffff3352),
chart2: chart2 ?? const Color(0xff8bdc00),
chart3: chart3 ?? const Color(0xff2890ff),
chart4: chart4 ?? const Color(0xffedba18),
chart5: chart5 ?? const Color(0xffed5700),
chrome: resolvedChrome,
panelBorder: edge(background, 9.6),
panelBorderHighlighted: raise(20.8),
rowHovered: raise(10.0),
rowText: rowText ?? text(0.814),
popoverItemHovered: raise(8.0),
// +6.2 over `card`, which is what a properties section is filled with -
// NOT off the ground. Measured off the background it sat under a point
// above its own fill and vanished into it.
propertiesSectionBorder: edge(resolvedCard, 6.2),
);
}
final Brightness brightness;
final Color background;
final Color foreground;
/// Raised surface. Cards, and the fill behind a properties section.
final Color card;
final Color popover;
final Color popoverBorder;
// tooltips get their own pair rather than riding popover's - they sit on
// top of everything and want more contrast than a panel-level surface.
final Color tooltipBackground;
final Color tooltipBorder;
final Color primary;
final Color primaryHovered;
final Color primaryForeground;
final Color secondary;
// secondary fill, hovered. used to be one hardcoded grey duplicated in
// button.dart and select.dart, which meant the crimson/light schemes both
// hovered to the same dark grey.
final Color secondaryHovered;
final Color secondaryForeground;
final Color muted;
final Color mutedForeground;
final Color destructive;
final Color border;
final Color divider;
/// One step below the ground. Side panes, list backgrounds, anything
/// recessed into the surface its sat on rather than lifted off it.
final Color surfaceSunken;
// The control family: the fill and stroke every control shares - text
// fields, selects, date inputs, outline and ghost buttons, checkboxes,
// radios, the colour swatch. NOT text-field-only, whatever the old names
// claimed.
final Color controlFill;
final Color controlFillHovered;
final Color controlFillFocused;
final Color controlBorder;
/// A Switch's track while its off. Its own slot because nothing else wants
/// this colour and it used to squat on `input`.
final Color switchTrackInactive;
final Color ring;
final Color chart1;
final Color chart2;
final Color chart3;
final Color chart4;
final Color chart5;
// header/footer chrome, nudged off background so it reads as chrome.
final Color chrome;
// borders of a content panel. The panel FILL is just `background` - all ten
// hand authored schemes had them identical, so theres no slot for it.
final Color panelBorder;
final Color panelBorderHighlighted;
/// Hovered row, in a list or a menu. One slot: menuItemHovered and
/// explorerRowHovered were the same colour in all ten schemes.
final Color rowHovered;
/// Resting label/icon colour for a row. Was explorerRowText, and
/// propertiesSectionLabel was the same colour in all ten schemes.
final Color rowText;
// Interactive rows inside popovers. Kept separate from row hover while the
// two interaction colours are being evaluated.
final Color popoverItemHovered;
/// Outline of an object properties section. Measured off `card`, which is
/// what fills one.
final Color propertiesSectionBorder;
ColourScheme copyWith({
Brightness? brightness,
Color? background,
Color? foreground,
Color? card,
Color? popover,
Color? popoverBorder,
Color? tooltipBackground,
Color? tooltipBorder,
Color? primary,
Color? primaryHovered,
Color? primaryForeground,
Color? secondary,
Color? secondaryHovered,
Color? secondaryForeground,
Color? muted,
Color? mutedForeground,
Color? destructive,
Color? border,
Color? divider,
Color? surfaceSunken,
Color? controlFill,
Color? controlFillHovered,
Color? controlFillFocused,
Color? controlBorder,
Color? switchTrackInactive,
Color? ring,
Color? chart1,
Color? chart2,
Color? chart3,
Color? chart4,
Color? chart5,
Color? chrome,
Color? panelBorder,
Color? panelBorderHighlighted,
Color? rowHovered,
Color? rowText,
Color? popoverItemHovered,
Color? propertiesSectionBorder,
}) {
return ColourScheme(
brightness: brightness ?? this.brightness,
background: background ?? this.background,
foreground: foreground ?? this.foreground,
card: card ?? this.card,
popover: popover ?? this.popover,
popoverBorder: popoverBorder ?? this.popoverBorder,
tooltipBackground: tooltipBackground ?? this.tooltipBackground,
tooltipBorder: tooltipBorder ?? this.tooltipBorder,
primary: primary ?? this.primary,
primaryHovered: primaryHovered ?? this.primaryHovered,
primaryForeground: primaryForeground ?? this.primaryForeground,
secondary: secondary ?? this.secondary,
secondaryHovered: secondaryHovered ?? this.secondaryHovered,
secondaryForeground: secondaryForeground ?? this.secondaryForeground,
muted: muted ?? this.muted,
mutedForeground: mutedForeground ?? this.mutedForeground,
destructive: destructive ?? this.destructive,
border: border ?? this.border,
divider: divider ?? this.divider,
surfaceSunken: surfaceSunken ?? this.surfaceSunken,
controlFill: controlFill ?? this.controlFill,
controlFillHovered: controlFillHovered ?? this.controlFillHovered,
controlFillFocused: controlFillFocused ?? this.controlFillFocused,
controlBorder: controlBorder ?? this.controlBorder,
switchTrackInactive: switchTrackInactive ?? this.switchTrackInactive,
ring: ring ?? this.ring,
chart1: chart1 ?? this.chart1,
chart2: chart2 ?? this.chart2,
chart3: chart3 ?? this.chart3,
chart4: chart4 ?? this.chart4,
chart5: chart5 ?? this.chart5,
chrome: chrome ?? this.chrome,
panelBorder: panelBorder ?? this.panelBorder,
panelBorderHighlighted:
panelBorderHighlighted ?? this.panelBorderHighlighted,
rowHovered: rowHovered ?? this.rowHovered,
rowText: rowText ?? this.rowText,
popoverItemHovered: popoverItemHovered ?? this.popoverItemHovered,
propertiesSectionBorder:
propertiesSectionBorder ?? this.propertiesSectionBorder,
);
}
// Accent override - swaps primary/ring to the given accent colour, and picks a
// readable foreground for it. Mirrors what shadcns recolor() did. Pass null
// (the "none" accent) to leave the scheme untouched.
ColourScheme withAccent(Color? accent) {
if (accent == null) return this;
return copyWith(
primary: accent,
primaryHovered: accent,
primaryForeground: _contrastColor(accent),
ring: accent,
);
}
static ColourScheme lerp(ColourScheme a, ColourScheme b, double t) {
if (t <= 0) return a;
if (t >= 1) return b;
Color c(Color x, Color y) => Color.lerp(x, y, t)!;
return ColourScheme(
brightness: t < 0.5 ? a.brightness : b.brightness,
background: c(a.background, b.background),
foreground: c(a.foreground, b.foreground),
card: c(a.card, b.card),
popover: c(a.popover, b.popover),
popoverBorder: c(a.popoverBorder, b.popoverBorder),
tooltipBackground: c(a.tooltipBackground, b.tooltipBackground),
tooltipBorder: c(a.tooltipBorder, b.tooltipBorder),
primary: c(a.primary, b.primary),
primaryHovered: c(a.primaryHovered, b.primaryHovered),
primaryForeground: c(a.primaryForeground, b.primaryForeground),
secondary: c(a.secondary, b.secondary),
secondaryHovered: c(a.secondaryHovered, b.secondaryHovered),
secondaryForeground: c(a.secondaryForeground, b.secondaryForeground),
muted: c(a.muted, b.muted),
mutedForeground: c(a.mutedForeground, b.mutedForeground),
destructive: c(a.destructive, b.destructive),
border: c(a.border, b.border),
divider: c(a.divider, b.divider),
surfaceSunken: c(a.surfaceSunken, b.surfaceSunken),
controlFill: c(a.controlFill, b.controlFill),
controlFillHovered: c(a.controlFillHovered, b.controlFillHovered),
controlFillFocused: c(a.controlFillFocused, b.controlFillFocused),
controlBorder: c(a.controlBorder, b.controlBorder),
switchTrackInactive: c(a.switchTrackInactive, b.switchTrackInactive),
ring: c(a.ring, b.ring),
chart1: c(a.chart1, b.chart1),
chart2: c(a.chart2, b.chart2),
chart3: c(a.chart3, b.chart3),
chart4: c(a.chart4, b.chart4),
chart5: c(a.chart5, b.chart5),
chrome: c(a.chrome, b.chrome),
panelBorder: c(a.panelBorder, b.panelBorder),
panelBorderHighlighted: c(
a.panelBorderHighlighted,
b.panelBorderHighlighted,
),
rowHovered: c(a.rowHovered, b.rowHovered),
rowText: c(a.rowText, b.rowText),
popoverItemHovered: c(a.popoverItemHovered, b.popoverItemHovered),
propertiesSectionBorder: c(
a.propertiesSectionBorder,
b.propertiesSectionBorder,
),
);
}
}
/// [base]'s lightness in CIE L*, 0 (black) to 100 (white).
double lstarOf(Color base) => _lstar(base);
/// [base] moved [points] in CIE L*, keeping its hue and saturation.
///
/// Reflects rather than clamps when theres no room that way: a colour that
/// cant get [points] lighter gets [points] darker instead, keeping the size of
/// the step and losing only its direction. A clamp doesnt shorten a step, it
/// deletes it - two slots asking for +6 and +11 against a near-white ground
/// both land on white and a distinction that exists in every other scheme is
/// gone.
///
/// This is the same step [ColourScheme.derive] is built out of, exposed so an
/// app deriving extra slots of its own (Arcs & Angles' canvas colours) walks
/// the identical ladder rather than reinventing a near-miss of it.
Color shiftLstar(Color base, double points) {
final l = _lstar(base);
final wanted = l + points;
return _atLstar(base, (wanted > 100 || wanted < 0) ? l - points : wanted);
}
// chrome is the deepest recessed slot the shared scheme has, so its want sets
// the squeeze factor for every other one.
const double _deepestSink = 12.6;
// ── CIE L* ───────────────────────────────────────────────────────────────
// The offsets above are all in L*, which is perceptually uniform - five
// points looks like the same step whether youre near black or near white.
// HSL lightness, which this used to use, very much does not.
double _channel(double v) =>
v <= 0.04045 ? v / 12.92 : math.pow((v + 0.055) / 1.055, 2.4).toDouble();
double _lstar(Color c) {
final y =
0.2126 * _channel(c.r) + 0.7152 * _channel(c.g) + 0.0722 * _channel(c.b);
return y > 0.008856 ? 116 * math.pow(y, 1 / 3).toDouble() - 16 : 903.3 * y;
}
// [base]'s hue and saturation at the given L*.
//
// Theres no closed form for this that keeps HSL saturation fixed, so it
// bisects on HSL lightness instead - which is fine, its monotonic in
// luminance and this runs once per scheme at startup, not per frame.
Color _atLstar(Color base, double target) {
final hsl = HSLColor.fromColor(base);
final want = target.clamp(0.0, 100.0);
var lo = 0.0;
var hi = 1.0;
for (var i = 0; i < 18; i++) {
final mid = (lo + hi) / 2;
if (_lstar(hsl.withLightness(mid).toColor()) < want) {
lo = mid;
} else {
hi = mid;
}
}
return hsl.withLightness((lo + hi) / 2).toColor();
}
// flips lightness to get a readable foreground on a given colour. same idea as
// shadcns getContrastColor (full luminance contrast).
Color _contrastColor(Color on) {
final hsl = HSLColor.fromColor(on);
final l = hsl.lightness;
final target = l >= 0.5 ? 0.0 : 1.0;
// nudge toward the target rather than pure black/white so it doesnt look harsh
final mixed = lerpDouble(l, target, 1.0)!;
return hsl.withLightness(mixed.clamp(0.0, 1.0)).toColor();
}
// a focus ring wants to read punchier than a resting "primary" swatch -
// lighter and more saturated, so it pops against whatevers behind it instead
// of just matching a button colour.
Color _brightenForSelectionRing(Color colour) {
final hsl = HSLColor.fromColor(colour);
return hsl
.withSaturation((hsl.saturation + 0.16).clamp(0.0, 1.0))
.withLightness((hsl.lightness + 0.13).clamp(0.0, 0.78))
.toColor();
}
+90
View File
@@ -0,0 +1,90 @@
import "package:flutter/widgets.dart";
import "package:google_fonts/google_fonts.dart";
import "package:garage_ui/theme/theme_data.dart";
export "package:garage_ui/theme/theme_data.dart";
export "package:garage_ui/theme/colour_scheme.dart";
export "package:garage_ui/theme/typography.dart";
// The apps theme. Hands the theme data down via a plain inherited scope (no
// animated wrapper, no per frame ColorScheme.lerp flattening the whole thing -
// this is the bit that used to feel second class), AND establishes a sane
// DefaultTextStyle + IconTheme so bare Text/Icon dont fall back to flutter's
// yellow-underline "unstyled" debug look (MaterialApp only styles text inside a
// Material, and our panels arent Material).
class GarageTheme extends StatelessWidget {
const GarageTheme({super.key, required this.data, required this.child});
final ThemeData data;
final Widget child;
static ThemeData of(BuildContext context) {
final t = maybeOf(context);
assert(
t != null,
"No GarageTheme found in context. Wrap the app in an GarageTheme.",
);
return t!;
}
static ThemeData? maybeOf(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<_GarageThemeScope>()
?.data;
}
@override
Widget build(BuildContext context) {
final cs = data.colorScheme;
return _GarageThemeScope(
data: data,
child: DefaultTextStyle(
// the one app-wide UI font, declared right here at the top of the tree.
// everything below that doesnt override fontFamily inherits Geist.
// (canvas-painted text - station labels, watermark - sits outside the
// widget tree so it isnt touched by this.)
style: GoogleFonts.geist(
color: cs.foreground,
// off the density, not a literal times scaling. this was the third
// independent source of text size in the package - controls read
// density.fontSize, the .xSmall()/.small()/.large() ladder now reads
// it too, and this floated free at 11 * scaling. Retune fontSize and
// page copy no longer stays behind while the controls move.
//
// fontSize, NOT textXs. The ladder above the control font is a set
// of MULTIPLIERS (xs 1.1x, sm 1.4x, lg 1.8x), so what textXs means
// depends on the tier: at A&A's 10 its 11, a hair over the control
// font and harmless, and at the product tier's 16 its 18 - body copy
// rendering BIGGER than the text inside the buttons and fields next
// to it. Thats backwards, and its what made the product surfaces
// read as oversized even with their control geometry correct.
//
// Body copy is the control font. A sentence and the text in the box
// under it are the same size, at every tier, by construction.
fontSize: data.density.fontSize,
// the important bit - kills the yellow underline.
decoration: TextDecoration.none,
fontWeight: FontWeight.w300,
),
child: IconTheme(
data: IconThemeData(
color: cs.foreground,
size: data.iconTheme.medium.size,
),
child: child,
),
),
);
}
}
class _GarageThemeScope extends InheritedWidget {
const _GarageThemeScope({required this.data, required super.child});
final ThemeData data;
@override
bool updateShouldNotify(_GarageThemeScope oldWidget) =>
oldWidget.data != data;
}
+88
View File
@@ -0,0 +1,88 @@
import "dart:ui" show Color;
import "package:flutter/widgets.dart" show Brightness;
import "package:garage_ui/theme/colour_scheme.dart";
/// Ready-made schemes, so a new app can look like a Garage app on line one.
///
/// Standing one up used to mean authoring 54 colours by hand before writing
/// any app code. These are the neutral pair, built through
/// [ColourScheme.derive] from four colours each — which is also the worked
/// example of how to make your own.
///
/// ```dart
/// GarageApp.router(
/// theme: ThemeData(colorScheme: GarageSchemes.dark),
/// ...
/// )
/// ```
///
/// Want your own hue? Change [ColourScheme.derive]'s `primary` and leave the
/// rest. Want one slot exact? Every derived slot has an override.
abstract final class GarageSchemes {
/// The neutral dark scheme. Same core Arcs & Angles' zinc is built on.
static final ColourScheme dark = ColourScheme.derive(
brightness: Brightness.dark,
background: const Color(0xff303030),
foreground: const Color(0xffe6e6e6),
primary: const Color(0xff4772b3),
);
/// The neutral light scheme — the same blue on a faintly cool near-white.
static final ColourScheme light = ColourScheme.derive(
brightness: Brightness.light,
background: const Color(0xfff2f2f4),
foreground: const Color(0xff1a1a1c),
primary: const Color(0xff3a63a1),
);
/// Carbon. True black, no chroma at all - built for oled panels, and the
/// scheme the Garage apps actually run.
///
/// It lives here rather than in each app because it didnt: the hub and Arcs
/// & Angles each kept their own copy and they drifted apart in twenty five
/// of fifty two slots before anyone noticed. One definition cant.
///
/// Carbon has 3.6 L* of room under its ground, against zinc's 19.9, so every
/// recessed rung squeezes into what there is - which is why chrome lands on
/// the floor without being told to. The pins are where carbon wants
/// something other than what the ladder gives it.
static final ColourScheme carbon = ColourScheme.derive(
brightness: Brightness.dark,
background: const Color(0xff0d0d0d),
foreground: const Color(0xfff2f2f2),
// no accent hue anywhere - primary is just a near white fill
primary: const Color(0xffe8e8e8),
primaryForeground: const Color(0xff000000),
ring: const Color(0xffffffff),
destructive: const Color(0xffc43333),
// the ladder puts these a shade off the floor. carbon wants the floor -
// the gutter between panels is the whole look.
popover: const Color(0xff000000),
controlFillFocused: const Color(0xff000000),
// above the ground rather than below it, which is what carbon has always
// done with muted. theres barely any "below" left to use.
muted: const Color(0xff0f0f0f),
controlFill: const Color(0xff080808),
tooltipBackground: const Color(0xff080808),
// NOT the ladder's +11.8 over the fill. Carbon cant recess a field far
// enough to read as recessed, so the stroke does all the separating and
// has to be the strongest thing on the control, not the weakest.
controlBorder: const Color(0xff2a2a2a),
chart1: const Color(0xfff45b69),
chart2: const Color(0xff86d957),
chart3: const Color(0xff62a7ff),
chart4: const Color(0xffffc857),
chart5: const Color(0xffff8c42),
);
/// Whichever of the pair matches [brightness].
static ColourScheme of(Brightness brightness) =>
brightness == Brightness.dark ? dark : light;
}
+169
View File
@@ -0,0 +1,169 @@
import "dart:ui" show Color, ImageFilter;
import "package:flutter/painting.dart" show HSLColor;
import "package:flutter/widgets.dart";
import "package:garage_ui/theme/garage_theme.dart";
// small pile of helpers the GarageUI widgets used to pull off the shadcn barrel.
// theyre generic, nothing shadcn specific, so we just keep our own copies.
// shadcns Color helpers that the widgets lean on.
extension ColorExtension on Color {
// scale the alpha channel by [factor] (0..1). shadcns scaleAlpha.
Color scaleAlpha(double factor) {
return withValues(alpha: (a * factor).clamp(0.0, 1.0));
}
// a readable foreground for this colour - flips lightness. shadcns getContrastColor.
Color getContrastColor([double luminanceContrast = 1]) {
final hsl = HSLColor.fromColor(this);
final l = hsl.lightness;
final target = l >= 0.5
? l - (l * luminanceContrast)
: l + ((1 - l) * luminanceContrast);
return hsl.withLightness(target.clamp(0.0, 1.0)).toColor();
}
}
// default anim duration shadcn used all over (hover/colour transitions etc).
const Duration kDefaultDuration = Duration(milliseconds: 150);
// widget value wins, then theme value, then the hard default. shadcns styleValue.
T styleValue<T>({T? widgetValue, T? themeValue, required T defaultValue}) {
return widgetValue ?? themeValue ?? defaultValue;
}
// we dropped shadcns density-insets system, so this is now just a passthrough -
// the GarageUI widgets only ever hand it plain EdgeInsets anyway.
EdgeInsetsGeometry resolveEdgeInsets(
EdgeInsetsGeometry padding,
double basePadding,
) {
return padding;
}
// shrinks a border radius by the border width so an inner surface tucks neatly
// inside its border. shadcns subtractByBorder.
BorderRadius subtractByBorder(BorderRadius radius, double borderWidth) {
Radius sub(Radius r) => Radius.elliptical(
(r.x - borderWidth).clamp(0.0, double.infinity),
(r.y - borderWidth).clamp(0.0, double.infinity),
);
return BorderRadius.only(
topLeft: sub(radius.topLeft),
topRight: sub(radius.topRight),
bottomLeft: sub(radius.bottomLeft),
bottomRight: sub(radius.bottomRight),
);
}
// used to be a density-aware Padding in shadcn. our padding is already resolved
// by the time it gets here, so this is just a Padding.
class DensityContainerPadding extends StatelessWidget {
const DensityContainerPadding({
super.key,
required this.padding,
required this.child,
});
final EdgeInsetsGeometry padding;
final Widget child;
@override
Widget build(BuildContext context) => Padding(padding: padding, child: child);
}
// shadcn used this to tell whether a surface was being rendered inside a sheet
// so it could drop its own rounding/border. the app doesnt use sheets, so its
// always false.
class SheetOverlayHandler {
const SheetOverlayHandler._();
static bool isSheetOverlay(BuildContext context) => false;
}
// draws a focus ring around a child when [focused]. shadcns FocusOutline, cut
// down to what the text field needs.
class FocusOutline extends StatelessWidget {
const FocusOutline({
super.key,
required this.child,
required this.focused,
this.borderRadius,
this.color,
this.width = 1.0,
});
final Widget child;
final bool focused;
final BorderRadiusGeometry? borderRadius;
final Color? color;
final double width;
@override
Widget build(BuildContext context) {
if (!focused) return child;
final ring = color ?? GarageTheme.of(context).colorScheme.ring;
return Container(
decoration: BoxDecoration(
borderRadius: borderRadius,
// draw the ring OUTSIDE the box so it doesnt inset the child and
// change the field size when focus comes and goes.
border: Border.all(
color: ring,
width: width,
strokeAlign: BorderSide.strokeAlignOutside,
),
),
child: child,
);
}
}
// blurs whatever is behind the child (glassmorphism on popovers/cards). ported
// straight from shadcns outlined_container.dart.
class SurfaceBlur extends StatefulWidget {
const SurfaceBlur({
super.key,
required this.child,
this.surfaceBlur,
this.borderRadius,
});
final Widget child;
final double? surfaceBlur;
final BorderRadiusGeometry? borderRadius;
@override
State<SurfaceBlur> createState() => _SurfaceBlurState();
}
class _SurfaceBlurState extends State<SurfaceBlur> {
final GlobalKey _mainContainerKey = GlobalKey();
@override
Widget build(BuildContext context) {
if (widget.surfaceBlur == null || widget.surfaceBlur! <= 0) {
return KeyedSubtree(key: _mainContainerKey, child: widget.child);
}
return Stack(
fit: StackFit.passthrough,
children: [
Positioned.fill(
child: ClipRRect(
borderRadius: widget.borderRadius ?? BorderRadius.zero,
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: widget.surfaceBlur!,
sigmaY: widget.surfaceBlur!,
),
// needs a child or it wont actually blur anything
child: const SizedBox(),
),
),
),
KeyedSubtree(key: _mainContainerKey, child: widget.child),
],
);
}
}
+573
View File
@@ -0,0 +1,573 @@
import "package:flutter/foundation.dart"
show TargetPlatform, defaultTargetPlatform;
import "package:flutter/widgets.dart";
import "package:garage_ui/theme/colour_scheme.dart";
import "package:garage_ui/theme/typography.dart";
/// The two control tiers. One type for both levels: hand it to [ThemeData] to
/// set the app-wide density, or to any control to override that control.
///
/// Replaces the pair this used to be - a `DensityMode` enum on the theme and a
/// separate `ControlDensity` class on widgets - which modelled the same two
/// values twice, in two shapes, and named the widget-level one after a single
/// widget despite Select, Menubar, MenuButton, MenuPopup, TextField and
/// TabView all taking it.
enum ControlDensity {
compact,
normal;
bool get isCompact => this == ControlDensity.compact;
/// The canonical token set for this tier, ignoring any theme tuning.
Density get canonical =>
isCompact ? const Density.compact() : const Density.normal();
/// The tier the theme is currently set to.
static ControlDensity of(ThemeData theme) => theme.density.control;
/// The token set a control on this tier should read.
///
/// When the tier matches the theme's, the theme's own [Density] is used, so
/// a tuned one survives. When a control asks for the other tier, it falls
/// back to that tier's canonical tokens.
Density tokens(ThemeData theme) =>
this == theme.density.control ? theme.density : canonical;
EdgeInsets resolve(ThemeData theme) => tokens(theme).buttonPadding;
/// Padding for an icon-only control, collapsed to its smallest side so the
/// control comes out square rather than inheriting the asymmetric
/// horizontal padding.
EdgeInsets resolveIcon(ThemeData theme) => _squarePadding(resolve(theme));
}
EdgeInsets _squarePadding(EdgeInsets p) {
final side = p.vertical < p.horizontal ? p.vertical / 2 : p.horizontal / 2;
return EdgeInsets.all(side);
}
/// The app-wide UI density.
///
/// ONE RULE: the fields on this class are the only hand-set numbers in the
/// control system. Everything else - line box, icon size, every vertical
/// padding, every control height - is a getter derived from them. If you find
/// yourself typing a pixel height anywhere else, it belongs here instead.
///
/// The heights are the configurable part. You say how tall a control should be
/// and the padding falls out of it:
///
/// lineBox = fontSize * lineHeight
/// controlPaddingY = (controlHeight - lineBox) / 2
///
/// NOT the other way round. Tuning padding until a height came out right is
/// what produced four different control heights, a `Transform.translate` in two
/// widgets, and 23/27 re-typed as literals in the explorer.
///
/// `lineBox` needs no font metrics: when a TextStyle carries an explicit
/// `height`, Flutter sizes the line box to exactly `fontSize * height` and does
/// not consult the font. That is what makes this whole chain deterministic and
/// testable - a style with a null `height` measures 1.0x in the test host and
/// ~1.25x in the app, which is how a ~26px TextField once passed a suite of
/// tests that all asserted 23.
class Density {
const Density({
this.control = ControlDensity.compact,
// ---- type ----
this.fontSize = 10.0,
this.lineHeight = 1.1,
// ---- height targets (configure these; paddings derive from them) ----
this.controlHeight = 23.0,
this.menuRowHeight = 20.0,
this.popupRowHeight = 20.0,
this.popupMaxRows = 12,
this.explorerRowHeight = 22.0,
// ---- horizontal + misc primitives ----
this.buttonPaddingX = 10.0,
this.controlGap = 4.0,
this.controlBorderWidth = 1.0,
this.explorerRowBasePadding = 4.0,
this.explorerRowEndPadding = 10.0,
this.listRowIndent = 6.0,
// ---- container-level spacing (panels, popovers, dialogs) ----
this.containerGap = 8.0,
this.containerPadding = 16.0,
// ---- optical ----
this.controlTextOffset = 0.0,
});
/// The compact tier, said out loud. Same as the unnamed constructor's
/// defaults - which is exactly why it exists, because `Density()` silently
/// meaning "compact" was a trap.
const Density.compact() : this();
const Density.normal()
: control = ControlDensity.normal,
fontSize = 10.0,
lineHeight = 1.1,
controlHeight = 27.0,
menuRowHeight = 26.0,
popupRowHeight = 26.0,
popupMaxRows = 12,
explorerRowHeight = 30.0,
buttonPaddingX = 12.0,
controlGap = 5.0,
controlBorderWidth = 1.0,
explorerRowBasePadding = 8.0,
explorerRowEndPadding = 14.0,
listRowIndent = 12.0,
containerGap = 10.0,
containerPadding = 20.0,
controlTextOffset = 0.0;
/// The product tier - for the Garage web apps rather than for A&A.
///
/// compact and normal are both answers to "a properties inspector has to sit
/// beside a viewport without eating it", which is why they share a 10px font
/// and differ only in control geometry. An app with no canvas inherits that
/// economy for nothing, so this tier scales the TYPE as well: 12px control
/// font, 33px controls, and a gap ladder off a base of 5.
///
/// `control` stays [ControlDensity.normal] deliberately. That enum is the
/// two-way geometry switch widgets branch on (`isCompact`), not a name for
/// the tier, and A&A switches over it exhaustively in its settings UI.
///
/// 12 * 1.25 = 15 line box, (33 - 15) / 2 = 9 padding. both whole, so the
/// control geometry stays exact - see [lineBox].
const Density.product()
: control = ControlDensity.normal,
fontSize = 12.0,
lineHeight = 1.25,
controlHeight = 33.0,
menuRowHeight = 33.0,
popupRowHeight = 33.0,
popupMaxRows = 10,
explorerRowHeight = 29.0,
buttonPaddingX = 10.0,
controlGap = 5.0,
controlBorderWidth = 1.0,
explorerRowBasePadding = 8.0,
explorerRowEndPadding = 12.0,
listRowIndent = 10.0,
containerGap = 10.0,
containerPadding = 20.0,
controlTextOffset = 0.0;
/// Which tier this token set represents.
final ControlDensity control;
// ---- type ----
/// Control font size. The app is a fixed compact desktop tool, so these are
/// final pixel values and are deliberately NOT multiplied by `scaling`.
final double fontSize;
/// Line box as a multiple of [fontSize]. Pinning this is what makes control
/// geometry font-independent - see the class doc.
final double lineHeight;
// ---- height targets ----
/// Button / text field / select trigger / icon button.
final double controlHeight;
/// Menu rows (MenuButton and friends).
///
/// Happens to equal [popupRowHeight] in both densities today - menus and
/// select popups are the same kind of surface. Kept as two tokens anyway so
/// one can move without dragging the other along.
final double menuRowHeight;
/// Rows inside a select popup.
final double popupRowHeight;
/// How many rows a select popup shows before it starts scrolling. A count,
/// not a pixel value - the height falls out of it via [popupMaxHeight], so a
/// denser popup gets shorter rather than showing more of them.
final int popupMaxRows;
/// Explorer tree rows.
final double explorerRowHeight;
// ---- horizontal + misc ----
final double buttonPaddingX;
/// Gap between a control's leading/trailing icon and its label. Sits INSIDE
/// the control, so it is tighter than the outer padding on purpose.
final double controlGap;
final double controlBorderWidth;
/// Leading indent applied per depth level in the explorer tree.
final double explorerRowBasePadding;
/// Trailing inset on an explorer row, so the hide toggle isnt sat right
/// under the scrollbar thumb.
final double explorerRowEndPadding;
/// Leading inset on a flat list row, before its icon. Distinct from
/// [explorerRowBasePadding], which is a per-depth indent in a tree.
///
/// Authored from the values the Lines slots list was already branching on by
/// hand. It and the explorer disagree about this inset (6/12 vs 4/8) and
/// always have - reconciling them is a visual decision, not a refactor.
final double listRowIndent;
// ---- container-level ----
/// Spacing between elements in a panel / popover / dialog. This is layout
/// spacing, NOT control-internal spacing - reach for [controlGap] inside a
/// control. (Replaces the old shadcn-inherited `baseGap`.)
final double containerGap;
/// Padding inside a panel / popover / dialog. (Replaces `baseContentPadding`
/// and `baseContainerPadding`, which always held the same value.)
final double containerPadding;
// ---- optical ----
/// Downward nudge applied to control content so it sits on its optical
/// centre rather than its geometric one. Negative moves it up.
///
/// This is the one value the maths cannot settle on its own - the line box is
/// exact, but where the ink sits inside it depends on the font's
/// ascent/descent split. So it is a judgement made by eye, once, here. It
/// used to be `Offset(0, 1)` hardcoded in Button and `Offset(0, 2)` in
/// Select, which is two judgements that disagreed.
///
/// Currently 0 - i.e. the geometric centre is what looks right in Geist at
/// this size. Keep the token even so: it is the knob, and a zero here costs
/// nothing because the controls skip the transform entirely when it is 0.
final double controlTextOffset;
// =========================================================================
// derived - do not hand-set any of these, and do not re-derive them at a
// call site
// =========================================================================
/// Height of one line of control text: `fontSize * lineHeight`, rounded to a
/// whole pixel.
///
/// Measured, not assumed. Against Georgia and Andale Mono (both natural ratio
/// 1.10) and the test host's fallback (1.00): when the product is a whole
/// number the engine lays the line box out at exactly that in all three, so
/// the font's own ascent/descent genuinely do not participate. That is what
/// makes control geometry font-independent and testable.
///
/// When the product is fractional the engine rounds it, and at a .5 tie the
/// direction is FONT-DEPENDENT (13.5 came out 14 in Andale, 13 in Georgia).
/// So keep `fontSize * lineHeight` on a whole number - 10 * 1.1 = 11 does -
/// and every height below is exact. The rounding here keeps the derivation
/// honest for other configs rather than quietly missing the target by a
/// fraction of a pixel.
double get lineBox => (fontSize * lineHeight).roundToDouble();
/// Control icons, sized against the TEXT rather than the line box.
///
/// This used to be `lineBox` - 15px at product - on the reasoning that an
/// icon-only control and a text control then come out the same height for
/// free. That holds, but it isnt what the eye measures: lucide glyphs fill
/// their box nearly edge to edge while a 12px font has a cap height around
/// 8.5px, so a line-box icon reads about 70% taller than the letters beside
/// it and every button with an icon in it looked slightly wrong.
///
/// Level with the font. 1.1x was the first attempt at the optical match and
/// still read a shade heavy next to the label beside it. Control HEIGHT is
/// unaffected either way: that comes from controlHeight, not from whats
/// inside.
double get iconSize => fontSize;
double get controlPaddingY => (controlHeight - lineBox) / 2;
double get menuRowPaddingY => (menuRowHeight - lineBox) / 2;
double get popupRowPaddingY => (popupRowHeight - lineBox) / 2;
double get explorerRowPaddingY => (explorerRowHeight - lineBox) / 2;
/// Height of a chrome bar - an editor's header or footer.
///
/// One [controlGap] above the control and one below, which is [gapMd] all
/// told. A bar sized any tighter than that isnt giving its contents a
/// margin so much as a haircut: the old hardcoded 30 left 1.5px over a
/// normal-tier control and would have been SHORTER than a product-tier one,
/// so a button in the header didnt fit the header.
///
/// Matching the vertical margin to the horizontal gap is the whole point -
/// a row of buttons then sits in an even field instead of one thats
/// generous side to side and tight top to bottom.
///
/// compact 23 + 8 = 31, normal 27 + 10 = 37, product 33 + 10 = 43
double get chromeBarHeight => controlHeight + gapMd;
/// Cap on a select popup's list. Counts rows only - the list's own padding
/// (and a search field, when there is one) sits on top, so the last row
/// clips slightly rather than landing flush. Thats deliberate: a half row
/// showing is the cheapest "theres more below" hint there is.
///
/// Was `kDefaultSelectMaxHeight = 240.0` in select.dart, hand-typed and then
/// multiplied by `scaling`, so it never moved with density at all.
double get popupMaxHeight => popupRowHeight * popupMaxRows;
/// Padding for a borderless control (ghost/primary button, etc).
EdgeInsets get buttonPadding => EdgeInsets.symmetric(
horizontal: buttonPaddingX,
vertical: controlPaddingY,
);
/// Padding for a bordered control. A BoxDecoration border adds its width to
/// the Container's layout, so the stroke comes out of the padding and the
/// outer height stays [controlHeight] either way.
EdgeInsets get borderedControlPadding => EdgeInsets.symmetric(
horizontal: buttonPaddingX - controlBorderWidth,
vertical: controlPaddingY - controlBorderWidth,
);
/// Text fields are bordered controls.
EdgeInsets get textFieldPadding => borderedControlPadding;
// ---- text ----
//
// The body text ladder. [fontSize] is the control font - what a button
// label, a field's text and a select trigger render at - and these are the
// sizes for text that ISN'T inside a control: dialog copy, headings, hints.
//
// Dialog copy reading a step larger than the button labels beneath it is
// deliberate - content and controls are different things. The bug was never
// that they differed, it's that they were UNLINKED: these were hardcoded
// 12/14/18 times `scaling`, while the control font comes off this class and
// is deliberately not scaled. So the intended 12-vs-10 held at scaling 1.0
// and drifted to 14.4-vs-10 at 1.2. The ratio moved with scaling, which is
// the actual defect.
//
// Deriving them from [fontSize] pins the ratio. At both canonical densities
// fontSize is 10, so these come out 12/14/18 - exactly the numbers they
// replaced, so nothing shifts at scaling 1.0.
/// The one step BELOW the control font - a caption sat under something,
/// not beside it. A property row's subtitle and description, and the same
/// tier anything else that explains a control rather than labelling it
/// should reach for.
///
/// This rung didn't exist. The ladder only ever went UP from [fontSize],
/// because in A&A the control font IS the smallest thing on screen - so a
/// page needing a caption had nowhere to go and hardcoded one. x0.875 lands
/// on 9 against a 10px control font, and 14 against a 16px one.
double get textXxs => (fontSize * 0.875).roundToDouble();
/// Content that sits near controls without being one - dialog copy, page
/// headings, hints.
///
/// x1.1, which lands on the ambient body size: 11 against a 10px control
/// font. It was x1.2 (12), matching the literal the shorthands used to
/// hardcode - but that put dialog copy and every page heading a fifth above
/// the controls beneath them and a pixel above ordinary body text, which
/// read as oversized rather than as hierarchy. Weight and colour carry the
/// emphasis instead.
double get textXs => (fontSize * 1.1).roundToDouble();
/// Geist's own line box, as a multiple of font size - hhea ascender 1005,
/// descender -295, lineGap 0 over a 1000 upem. The package hardcodes Geist
/// (see GarageTheme), and a label sets no explicit `height`, so this is the
/// ratio its line actually lays out at. Flutter rounds each line to a whole
/// pixel, which is why the derivations below round rather than ceil.
static const double geistLineRatio = 1.3;
/// Height of a labelled row's label column - the label on [textXs] with a
/// subtitle under it on [textXxs], both on the font's own line box.
///
/// Worth having as a token because it OUTGROWS [controlHeight] at the small
/// tiers: 26 against a 23px compact control. A row sized on controlHeight
/// alone therefore got its height from the label rather than from the
/// control, which is backwards and moves with the font.
double get labelColumnHeight =>
(textXs * geistLineRatio).roundToDouble() +
(textXxs * geistLineRatio).roundToDouble();
/// Minimum height of a [PropertyRow]. Clears whichever of the control and
/// the label column is taller, plus a step, so neither one is the thing
/// setting the row height and a row is the same height with or without a
/// subtitle.
double get propertyRowHeight =>
(labelColumnHeight > controlHeight ? labelColumnHeight : controlHeight) +
gapXxs;
/// Body copy that wants to read a step above the controls.
double get textSm => (fontSize * 1.4).roundToDouble();
/// Headings.
double get textLg => (fontSize * 1.8).roundToDouble();
// ---- layout spacing ----
//
// The gap scale. Layout spacing BETWEEN widgets - what a call site reaches
// for when it puts a `Gap` between two things. Distinct from [controlGap],
// which is spacing INSIDE a control, and which is the base unit here.
//
// Before this existed, call sites picked `Gap(n)` literals by eye. That was
// documented rather than derived, and the apps drifted off it - the two
// Garage web frontends between them had sixteen distinct gap values,
// including a 3, a 5 and ten 14s that no scale would have produced.
//
// The steps are multiples of [controlGap], which makes [gapMd] equal to
// [containerGap] and [gapXl] equal to [containerPadding] at both densities.
// That agreement isn't arranged, it's what those two tokens already were -
// which is the evidence the base unit is right.
//
// Rounded because the normal density's base is 5, and a 1.5x step off it
// lands on 7.5. A fractional gap isn't wrong the way a fractional line box
// is (nothing derives from it), but a whole pixel won't seam on a fractional
// device ratio, so it's free to keep them whole.
/// Hairline separation - a label sat directly above its value.
double get gapXxs => (controlGap * 0.5).roundToDouble();
/// Tight. Icon-adjacent, or items that read as one unit.
double get gapXs => controlGap;
/// Snug, between [gapXs] and the default.
double get gapSm => (controlGap * 1.5).roundToDouble();
/// The default. "These two things are related but distinct." When a call
/// site has no particular reason to pick another step, it wants this one.
double get gapMd => controlGap * 2;
/// Section-level: separates groups within a panel or form.
double get gapLg => controlGap * 3;
/// Between major blocks of a layout.
double get gapXl => controlGap * 4;
/// The largest step - page-level separation, above the panel scale.
double get gapXxl => controlGap * 6;
}
/// Icon sizes. `small` is the control icon and is derived from the density's
/// line box so it can never drift from the text beside it.
class IconThemeTokens {
const IconThemeTokens({
required this.small,
required this.medium,
required this.large,
});
final IconThemeData small;
final IconThemeData medium;
final IconThemeData large;
static IconThemeTokens forDensity(
Density density,
double scaling,
Color color,
) => IconThemeTokens(
small: IconThemeData(size: density.iconSize, color: color),
medium: IconThemeData(size: 20 * scaling, color: color),
large: IconThemeData(size: 24 * scaling, color: color),
);
}
// The apps first class theme data. Everything the widgets used to pull off
// shadcns ThemeData (colours, the global scaling, radius tokens, icon sizes,
// the optional surface glass) now lives here as our own thing.
class ThemeData {
ThemeData({
required this.colorScheme,
this.scaling = 1.0,
this.radius = 0.5,
this.surfaceOpacity,
this.surfaceBlur,
this.enableFeedback,
this.panelRadius = 10,
this.panelGap = 5,
Density density = const Density(),
Typography? typography,
IconThemeTokens? iconTheme,
}) : density = density,
// typography and icon sizes are derived from the density so the control
// type, the control icon and the control height all move together.
typography = typography ?? Typography.forDensity(density),
iconTheme =
iconTheme ??
IconThemeTokens.forDensity(density, scaling, colorScheme.foreground);
final ColourScheme colorScheme;
// chrome panel layout - corner radius of the docked panels, and the gap
// around + between them in the shell.
final double panelRadius;
final double panelGap;
final Typography typography;
final Density density;
// haptic/click feedback toggle. null = decide by platform (mobile on).
final bool? enableFeedback;
// used by controls that behave differently on touch platforms.
TargetPlatform get platform => defaultTargetPlatform;
// global ui scale. was shadcns AdaptiveScaling(0.75). widgets multiply their
// paddings/sizes by this to stay the size they always were.
final double scaling;
// base radius (rem-ish). the tokens below are derived from it exactly the way
// shadcn derived theirs (radius * step).
final double radius;
final IconThemeTokens iconTheme;
// optional surface glassmorphism - null means opaque / no blur, same defaults
// shadcn shipped.
final double? surfaceOpacity;
final double? surfaceBlur;
double get radiusXs => radius * 4;
double get radiusSm => radius * 8;
double get radiusMd => radius * 12;
double get radiusLg => radius * 16;
double get radiusXl => radius * 20;
double get radiusXxl => radius * 24;
BorderRadius get borderRadiusXs => BorderRadius.circular(radiusXs);
BorderRadius get borderRadiusSm => BorderRadius.circular(radiusSm);
BorderRadius get borderRadiusMd => BorderRadius.circular(radiusMd);
BorderRadius get borderRadiusLg => BorderRadius.circular(radiusLg);
BorderRadius get borderRadiusXl => BorderRadius.circular(radiusXl);
BorderRadius get borderRadiusXxl => BorderRadius.circular(radiusXxl);
Radius get radiusMdRadius => Radius.circular(radiusMd);
Radius get radiusLgRadius => Radius.circular(radiusLg);
Radius get radiusXlRadius => Radius.circular(radiusXl);
ThemeData copyWith({
ColourScheme? colorScheme,
double? scaling,
double? radius,
double? surfaceOpacity,
double? surfaceBlur,
Typography? typography,
Density? density,
bool? enableFeedback,
double? panelRadius,
double? panelGap,
IconThemeTokens? iconTheme,
}) {
return ThemeData(
colorScheme: colorScheme ?? this.colorScheme,
scaling: scaling ?? this.scaling,
radius: radius ?? this.radius,
surfaceOpacity: surfaceOpacity ?? this.surfaceOpacity,
surfaceBlur: surfaceBlur ?? this.surfaceBlur,
typography: typography ?? this.typography,
density: density ?? this.density,
enableFeedback: enableFeedback ?? this.enableFeedback,
panelRadius: panelRadius ?? this.panelRadius,
panelGap: panelGap ?? this.panelGap,
iconTheme: iconTheme ?? this.iconTheme,
);
}
}
+105
View File
@@ -0,0 +1,105 @@
import "package:flutter/widgets.dart";
import "package:google_fonts/google_fonts.dart";
import "package:garage_ui/theme/garage_theme.dart";
// The apps type scale. shadcn exposed a big Typography object; the GarageUI widgets
// only reach for the small set below. `small` is a size style, `medium`/`normal`
// are weight styles, and `mono` swaps the font family while inheriting the
// ambient size/weight unless a caller overrides them.
class Typography {
const Typography({
this.normal = const TextStyle(fontWeight: FontWeight.w400),
this.medium = const TextStyle(fontWeight: FontWeight.w500),
this.semiBold = const TextStyle(fontWeight: FontWeight.w600),
// `small` is the shared control font used by buttons, fields and selects.
// Prefer [Typography.forDensity] over setting this by hand - the size
// and line height belong to the density, and the whole control geometry
// chain hangs off them.
this.small = const TextStyle(
fontSize: 10,
height: 1.1,
fontWeight: FontWeight.w400,
),
});
/// Builds the control type from the density, so `small` can never drift from
/// the line box the control heights are derived from.
factory Typography.forDensity(Density density) => Typography(
small: TextStyle(
fontSize: density.fontSize,
height: density.lineHeight,
fontWeight: FontWeight.w400,
),
);
final TextStyle normal;
final TextStyle medium;
/// Emphasis above [medium] - section labels, a dialog's title. Was a
/// FontWeight.w600 literal in menu.dart, properties.dart and toast.dart.
final TextStyle semiBold;
final TextStyle small;
TextStyle sansStyle(TextStyle style) =>
GoogleFonts.geist(textStyle: style, fontWeight: style.fontWeight);
TextStyle get mono => GoogleFonts.geistMono();
TextStyle monoStyle(TextStyle style) =>
GoogleFonts.geistMono(textStyle: style, fontWeight: style.fontWeight);
}
// shadcn hung these little text helpers off every widget (usually a Text). they
// merge a style change over whatever DefaultTextStyle is in scope. sizes are
// multiplied by the theme scaling so they track the rest of the ui.
extension TextStyleExtension on Widget {
// sizes come off the density's text ladder, NOT a literal times scaling -
// see Density.textXs for why. scaling is left out on purpose: Density isn't
// scaled, and these have to stay in step with the control font.
Widget xSmall() => _StyledText(
child: this,
style: (t) => TextStyle(fontSize: t.density.textXs),
);
Widget small() => _StyledText(
child: this,
style: (t) => TextStyle(fontSize: t.density.textSm),
);
Widget large() => _StyledText(
child: this,
style: (t) => TextStyle(fontSize: t.density.textLg),
);
Widget medium() => _StyledText(
child: this,
style: (t) => const TextStyle(fontWeight: FontWeight.w500),
);
Widget semiBold() => _StyledText(
child: this,
style: (t) => const TextStyle(fontWeight: FontWeight.w600),
);
Widget bold() => _StyledText(
child: this,
style: (t) => const TextStyle(fontWeight: FontWeight.w700),
);
Widget muted() => _StyledText(
child: this,
style: (t) => TextStyle(color: t.colorScheme.mutedForeground),
);
}
typedef _StyleFromTheme = TextStyle Function(ThemeData theme);
class _StyledText extends StatelessWidget {
const _StyledText({required this.child, required this.style});
final Widget child;
final _StyleFromTheme style;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
return DefaultTextStyle.merge(style: style(theme), child: child);
}
}
+471
View File
@@ -0,0 +1,471 @@
// App-level toast entry point, built on top of overlay.dart's generic
// showToast/ToastLocation/_ToastHost plumbing rather than replacing it.
//
// Two looks live side by side:
// - compact/touch layouts keep the plain SurfaceCard+Basic toast from
// before this file existed - bottom right of the WINDOW, no bar, no
// close button, goes through showToast()'s own root-Overlay host.
// - wide desktop layouts get the redesigned card below: severity icon,
// a countdown bar along the bottom, an explicit close button - anchored
// to the top right of the CANVAS specifically (not the window) by
// [CanvasToastLayer], which sits inside the canvas's own Stack instead
// of the root Overlay. that's a deliberate second, independent path
// rather than reusing _ToastCardState's timer/hover state - there's no
// clean way to reach into that private state from outside overlay.dart,
// and duplicating "pause on hover, count down, close" is cheap next to
// trying to thread a canvas rect through the root Overlay's coordinate
// space instead.
import "dart:async";
import "package:flutter/semantics.dart";
import "package:flutter/widgets.dart";
import "package:flutter_lucide/flutter_lucide.dart";
import "package:garage_ui/button.dart";
import "package:garage_ui/extensions.dart";
import "package:garage_ui/overlay.dart";
import "package:garage_ui/surface.dart";
import "package:garage_ui/theme/garage_theme.dart";
import "package:garage_ui/theme/support.dart";
enum ToastSeverity { info, success, error }
class ToastRequest {
final Object id;
final String title;
final String? subtitle;
final ToastSeverity severity;
// null = stays up until the user closes it - no timer, no drain bar.
final Duration? showDuration;
ToastRequest({
required this.id,
required this.title,
this.subtitle,
required this.severity,
required this.showDuration,
});
}
// plain singleton rather than something threaded through providers - toasts
// are fire and forget from anywhere (editor actions, dialogs, panels) and
// theres only ever one CanvasToastLayer listening at a time.
class _DesktopToastQueue extends ChangeNotifier {
_DesktopToastQueue._();
static final _DesktopToastQueue instance = _DesktopToastQueue._();
final List<ToastRequest> requests = [];
void add(ToastRequest r) {
requests.add(r);
notifyListeners();
}
void remove(Object id) {
final before = requests.length;
requests.removeWhere((r) => r.id == id);
if (requests.length != before) notifyListeners();
}
}
/// Shows an app toast. [isMobile] lets a caller that already knows better
/// (an editor variant flag, say) settle it explicitly; left null it falls
/// back to a width check so a plain call still does the sensible thing.
/// [showDuration] null means the toast stays up until closed by hand - the
/// desktop card just skips the timer/bar entirely; the mobile toast's own
/// showToast() primitive has no concept of "forever", so it gets a long but
/// finite stand-in instead.
void showAppToast({
required BuildContext context,
required String title,
String? subtitle,
ToastSeverity severity = ToastSeverity.info,
Duration? showDuration = const Duration(seconds: 5),
bool? isMobile,
}) {
final mobile = isMobile ?? MediaQuery.sizeOf(context).width < 900;
if (mobile) {
showToast(
context: context,
location: ToastLocation.bottomRight,
showDuration: showDuration ?? const Duration(days: 1),
builder: (context, overlay) => SurfaceCard(
child: Basic(
title: Text(title),
subtitle: subtitle == null ? null : Text(subtitle),
),
),
);
return;
}
_DesktopToastQueue.instance.add(
ToastRequest(
id: UniqueKey(),
title: title,
subtitle: subtitle,
severity: severity,
showDuration: showDuration,
),
);
}
/// Sits inside the canvas's own Stack (not the root Overlay) so it anchors
/// to the canvas's top-right corner rather than the window's. Self-hides
/// when theres nothing queued, so its basically free to leave mounted.
class CanvasToastLayer extends StatefulWidget {
const CanvasToastLayer({super.key});
@override
State<CanvasToastLayer> createState() => _CanvasToastLayerState();
}
class _CanvasToastLayerState extends State<CanvasToastLayer> {
@override
void initState() {
super.initState();
_DesktopToastQueue.instance.addListener(_onChange);
}
@override
void dispose() {
_DesktopToastQueue.instance.removeListener(_onChange);
super.dispose();
}
void _onChange() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final requests = _DesktopToastQueue.instance.requests;
if (requests.isEmpty) return const SizedBox.shrink();
final theme = GarageTheme.of(context);
// containerPadding is a "form/dialog" spacing token - too generous for
// sitting right at the canvas edge. containerGap reads much closer to
// how other edge-pinned canvas chrome (HUD, controls overlay) sits.
final pad = theme.density.containerGap;
return Positioned(
top: pad,
right: pad,
width: 300 * theme.scaling,
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
alignment: Alignment.topRight,
curve: Curves.easeOut,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final r in requests)
Padding(
key: ValueKey(r.id),
padding: EdgeInsets.only(bottom: theme.density.containerGap),
child: _ToastCard(
request: r,
onDismiss: () => _DesktopToastQueue.instance.remove(r.id),
),
),
],
),
),
);
}
}
/// The desktop toast card - severity icon, title/subtitle, close button, and
/// a countdown bar along the bottom that drains as [ToastRequest.showDuration]
/// elapses. Hovering pauses the countdown (bar freezes); leaving resumes it
/// from wherever it left off, not a full reset.
class _ToastCard extends StatefulWidget {
const _ToastCard({required this.request, required this.onDismiss});
final ToastRequest request;
final VoidCallback onDismiss;
@override
State<_ToastCard> createState() => _ToastCardState();
}
class _ToastCardState extends State<_ToastCard> with TickerProviderStateMixin {
late final AnimationController _entry;
// null when the request has no showDuration - permanent, no countdown, no
// bar, closes only via the X.
AnimationController? _progress;
bool _closing = false;
@override
void initState() {
super.initState();
// A toast never takes focus, so nothing walks a screen reader onto it -
// it has to be pushed. The liveRegion flag below covers the case where
// the card is already mounted and its text changes; this covers the far
// more common one, which is the card appearing at all.
final r = widget.request;
SemanticsService.announce(
r.subtitle == null ? r.title : "${r.title}. ${r.subtitle}",
// toasts sit top-right in LTR; the direction only steers where the
// announcement is attributed, not what gets read.
TextDirection.ltr,
assertiveness: r.severity == ToastSeverity.error
? Assertiveness.assertive
: Assertiveness.polite,
);
_entry = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 220),
)..forward();
final duration = widget.request.showDuration;
if (duration != null) {
final progress = AnimationController(vsync: this, duration: duration);
progress.addStatusListener(_onProgressStatus);
progress.forward();
_progress = progress;
}
}
void _onProgressStatus(AnimationStatus status) {
if (status == AnimationStatus.completed) _startClose();
}
void _startClose() {
if (_closing) return;
_closing = true;
_progress?.stop();
unawaited(
_entry.reverse().whenComplete(() {
if (mounted) widget.onDismiss();
}),
);
}
void _pause() {
if (!_closing) _progress?.stop();
}
void _resume() {
if (!_closing && _progress?.isAnimating == false) _progress!.forward();
}
@override
void dispose() {
_entry.dispose();
_progress?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scheme = theme.colorScheme;
final IconData icon;
final Color accent;
switch (widget.request.severity) {
case ToastSeverity.success:
icon = LucideIcons.circle_check;
accent = scheme.chart2;
break;
case ToastSeverity.error:
icon = LucideIcons.circle_alert;
accent = scheme.destructive;
break;
case ToastSeverity.info:
icon = LucideIcons.info;
accent = scheme.primary;
break;
}
return AnimatedBuilder(
animation: _entry,
builder: (context, child) {
final t = Curves.easeOutCubic.transform(_entry.value.clamp(0.0, 1.0));
return Opacity(
opacity: t,
child: Transform.translate(
offset: Offset((1 - t) * 24, 0),
child: child,
),
);
},
child: Semantics(
liveRegion: true,
container: true,
child: MouseRegion(
onEnter: (_) => _pause(),
onExit: (_) => _resume(),
child: SurfaceCard(
padding: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
// same fill/border pair AlertDialog uses (ModalContainer) rather
// than the plain card colours - a toast is a floating overlay like
// a dialog, not an in-canvas panel, so it reads better matching that.
filled: true,
fillColor: scheme.popover,
borderColor: scheme.popoverBorder,
borderWidth: 1 * theme.scaling,
// xxl matches the dialog's colours but was too pillowy for a
// toast this short - md keeps the family resemblance without it.
borderRadius: theme.borderRadiusMd,
// Column, not Stack+Positioned - a Positioned bar doesn't grow the
// Stack's own size, it just overlays wherever "bottom: 0" lands
// WITHIN whatever height the text already claimed. for a long,
// wrapped subtitle that's directly behind the last line of text -
// invisible. a real Column child always gets its own reserved
// strip below the content, however tall that content gets.
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Mirrors AlertDialog's own Row/Column, not Basic's generic
// list-tile styling - same icon treatment (iconLarge), and
// title/subtitle set to the SAME text style, distinguished by
// colour only (full foreground vs muted) rather than size or
// weight, same as the dialog's title/content pairing.
Padding(
padding: EdgeInsets.all(theme.density.containerGap),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// same border/fill/radius formula as ButtonVariance.outline
// (_buttonOutlineDecoration) - reads as a matching control
// rather than a bespoke badge. icon stays accent-coloured
// (not the outline button's usual mutedForeground) since
// that colour is the whole point of a severity icon.
// no * scaling here either - every explicit Icon(size:)
// literal elsewhere in the app (explorer rows included)
// is a flat number, only the iconSmall/Medium/Large
// extensions multiply by scaling.
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: const Color(0x00000000),
border: Border.all(
color: scheme.controlBorder,
width: theme.density.controlBorderWidth,
),
borderRadius: theme.borderRadiusMd,
),
child: Center(
child: Icon(
icon,
size: 18,
color: const Color(0xFFFFFFFF),
),
),
),
SizedBox(width: theme.density.containerGap),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// no fontSize at all, same as the explorer panel's
// _RootRow label - both inherit GarageTheme's own
// root DefaultTextStyle (11 * scaling) instead of a
// second hand-typed number that can drift from it.
// fontWeight w400 matches that same row's override;
// colour is the only thing telling title/subtitle
// apart.
Text(
widget.request.title,
style: TextStyle(
fontWeight: FontWeight.w400,
color: scheme.foreground,
),
),
if (widget.request.subtitle
case final subtitle?) ...[
SizedBox(
height: theme.density.containerGap * 0.375,
),
Text(
subtitle,
style: TextStyle(
fontWeight: FontWeight.w400,
color: scheme.mutedForeground,
),
),
],
],
),
),
SizedBox(width: theme.density.containerGap),
IconButton.outline(
icon: const Icon(LucideIcons.x).iconSmall,
onPressed: _startClose,
),
],
),
),
if (_progress case final progress?)
AnimatedBuilder(
animation: progress,
builder: (context, _) => _ToastDrainBar(
remainingFraction: 1 - progress.value,
color: accent,
trackColor: scheme.popoverBorder,
thickness: 3,
),
),
],
),
),
),
),
);
}
}
class _ToastDrainBar extends StatelessWidget {
const _ToastDrainBar({
required this.remainingFraction,
required this.color,
required this.trackColor,
required this.thickness,
});
final double remainingFraction;
final Color color;
final Color trackColor;
final double thickness;
@override
Widget build(BuildContext context) {
// Row/Expanded rather than Stack + Align + FractionallySizedBox. the
// latter renders nothing here: an unparented ColoredBox has no intrinsic
// size, so once FractionallySizedBox hands it a loose constraint it
// collapses to zero width and the whole bar disappears. Expanded's flex
// is resolved against the Row's own width, so each half always gets a
// real tight constraint no matter how the bar is nested.
final fill = (remainingFraction.clamp(0.0, 1.0) * 1000).round();
final rest = 1000 - fill;
return SizedBox(
height: thickness,
child: Row(
// stretch is load-bearing, NOT cosmetic. a ColoredBox with no child
// collapses to the smallest size its constraints allow, and Row's
// default (center) hands children a LOOSE height - so both halves
// sized to height 0 and the bar vanished while still "building"
// perfectly happily. stretch makes that height tight.
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (fill > 0)
Expanded(
flex: fill,
child: ColoredBox(color: color),
),
if (rest > 0)
Expanded(
flex: rest,
child: ColoredBox(color: trackColor.scaleAlpha(0.4)),
),
],
),
);
}
}