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
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 IMBENJI.NET LTD
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+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)),
),
],
),
);
}
}
+64
View File
@@ -0,0 +1,64 @@
import Cocoa
import FlutterMacOS
// Hides the OS cursor and keeps it pinned in place for the duration of a
// drag, streaming raw pointer deltas back to Dart ourselves.
//
// Why not just let Flutters own pointer events drive this: Flutter computes
// PointerEvent.delta from the ABSOLUTE cursor position of consecutive
// events, not from raw hardware deltaX/deltaY. An earlier version tried to
// keep the cursor pinned by warping it back to an anchor point after every
// move while staying associated in between - that still let the OS visibly
// move the cursor between warps, and the warps themselves raced against
// real movement over the async Dart<->native round trip.
//
// The actual fix: CGAssociateMouseAndMouseCursorPosition(false) for the
// WHOLE gesture, not toggled per-event. WindowServer then simply stops
// moving the on-screen cursor at all in response to hardware input - no
// per-frame warping needed. mouseDragged events keep firing completely
// normally while disassociated, with correct deltaX/deltaY reflecting real
// HID movement (its only locationInWindow that freezes) - which is exactly
// why we read deltaX/deltaY here instead of position. Re-associating on
// unlock resumes normal 1:1 tracking from wherever the cursor was frozen,
// so it naturally reappears back where the drag started.
final class CursorLock {
static let shared = CursorLock()
private var locked = false
private var monitor: Any?
// strong on purpose - CursorLock.shared is a permanent singleton, theres
// no retain cycle risk, and a weak ref here previously meant the channel
// (whose only other strong reference was a local `let` in awakeFromNib)
// got deallocated the moment that function returned, silently breaking
// delta forwarding.
private var channel: FlutterMethodChannel?
func attach(channel: FlutterMethodChannel) {
self.channel = channel
}
func lock() {
guard !locked else { return }
locked = true
NSCursor.hide()
CGAssociateMouseAndMouseCursorPosition(0)
monitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDragged]) { [weak self] event in
guard let self, self.locked else { return event }
self.channel?.invokeMethod("scrubDelta", arguments: ["dx": event.deltaX, "dy": event.deltaY])
// swallowed - Flutters own position for this event is frozen (and
// therefore meaningless) while disassociated, no point forwarding it.
return nil
}
}
func unlock() {
guard locked else { return }
locked = false
if let monitor {
NSEvent.removeMonitor(monitor)
}
monitor = nil
CGAssociateMouseAndMouseCursorPosition(1)
NSCursor.unhide()
}
}
@@ -0,0 +1,58 @@
import Cocoa
import FlutterMacOS
// Entry point Flutter calls into via RegisterGeneratedPlugins - this is what
// makes CursorLock and the eyedropper "just work" for any app that depends
// on garage_ui, without that app having to hand-wire the channels itself in
// its own MainFlutterWindow.swift. Used to be duplicated per-app, which is
// exactly how it silently went stale (renamed channel on one side, not the
// other) and why a brand new app (no Runner wiring at all) got a
// MissingPluginException out of the box.
public class GarageUiPlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
let instance = GarageUiPlugin()
let cursorLockChannel = FlutterMethodChannel(
name: "garage/cursor_lock",
binaryMessenger: registrar.messenger
)
// CursorLock needs to hang on to the channel itself so it can push
// scrubDelta calls back to dart - see its own header for why a plain
// local var here isnt enough (it needs a home that outlives this fn)
CursorLock.shared.attach(channel: cursorLockChannel)
registrar.addMethodCallDelegate(instance, channel: cursorLockChannel)
let eyedropperChannel = FlutterMethodChannel(
name: "garage/eyedropper",
binaryMessenger: registrar.messenger
)
registrar.addMethodCallDelegate(instance, channel: eyedropperChannel)
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "lock":
CursorLock.shared.lock()
result(nil)
case "unlock":
CursorLock.shared.unlock()
result(nil)
case "isAvailable":
if #available(macOS 10.15, *) {
result(true)
} else {
result(false)
}
case "pick":
if #available(macOS 10.15, *) {
ScreenColorSampler.shared.show(result: result)
} else {
// dart reads nil as a cancel, which is close enough - it only ever
// asks for a pick after isAvailable said yes anyway
result(nil)
}
default:
result(FlutterMethodNotImplemented)
}
}
}
@@ -0,0 +1,58 @@
import Cocoa
import FlutterMacOS
// Screen wide colour picking via NSColorSampler - the same magnifier loupe
// the system colour panel hands you when you click its eyedropper.
//
// Worth knowing: this runs out of process (WindowServer does the actual
// grabbing), so unlike anything built on CGDisplayCreateImage it needs NO
// screen recording permission and never triggers that prompt. It also draws
// its own zoomed preview, which is why the dart side doesnt bother streaming
// a live preview while this is up - theres nothing to add.
//
// Its 10.15+. Older systems just fall through to sampling the apps own
// window in dart, so the button never disappears on anyone.
@available(macOS 10.15, *)
final class ScreenColorSampler {
static let shared = ScreenColorSampler()
// held for the duration of the session. NSColorSampler is one shot per
// instance and only one can be on screen at a time, so this doubles as the
// "already sampling" flag.
private var sampler: NSColorSampler?
func show(result: @escaping FlutterResult) {
guard sampler == nil else {
result(nil)
return
}
let sampler = NSColorSampler()
self.sampler = sampler
sampler.show { [weak self] color in
self?.sampler = nil
// nil means escape / clicked away, thats a cancel not an error
guard let color = color else {
result(nil)
return
}
// the sample comes back in whatever space the sampled display uses,
// convert or the numbers wont match the hex the user sees anywhere else
guard let srgb = color.usingColorSpace(.sRGB) else {
result(nil)
return
}
func channel(_ value: CGFloat) -> Int {
return Int((min(max(value, 0), 1) * 255).rounded())
}
result([
"r": channel(srgb.redComponent),
"g": channel(srgb.greenComponent),
"b": channel(srgb.blueComponent),
])
}
}
}
+27
View File
@@ -0,0 +1,27 @@
#
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
# Run `pod lib lint garage_ui.podspec` to validate before publishing.
#
Pod::Spec.new do |s|
s.name = 'garage_ui'
s.version = '0.0.1'
s.summary = 'Native macOS glue for garage_ui.'
s.description = <<-DESC
Native macOS support for garage_ui - cursor lock (for text field scrubbing)
and the NSColorSampler-backed eyedropper. Every other platform stays pure
dart, this podspec only exists so macOS apps that depend on garage_ui get
both wired up automatically instead of having to hand-register the channels
themselves in MainFlutterWindow.swift.
DESC
s.homepage = 'http://example.com'
s.license = { :type => 'MIT' }
s.author = { 'Garage' => 'dev@example.com' }
s.source = { :path => '.' }
s.source_files = 'Classes/**/*'
s.dependency 'FlutterMacOS'
s.platform = :osx, '10.15'
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
s.swift_version = '5.0'
end
+24
View File
@@ -0,0 +1,24 @@
name: garage_ui
description: A compact, desktop-first Flutter UI system for Garage applications.
publish_to: none
version: 0.1.0
environment:
sdk: ^3.10.0-75.1.beta
dependencies:
flutter:
sdk: flutter
flutter_lucide: ^1.11.0
google_fonts: ^8.0.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter:
plugin:
platforms:
macos:
pluginClass: GarageUiPlugin
+185
View File
@@ -0,0 +1,185 @@
import "package:flutter/rendering.dart";
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// A centred label sat in an Expanded, so it centred in the space LEFT OVER
// after a leading icon rather than across the button - one icon threw the text
// off centre by half its own width plus the gap.
void main() {
Widget host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(child: SizedBox(width: 300, child: child)),
),
);
Future<double> offset(WidgetTester t, Widget b) async {
await t.pumpWidget(host(b));
return t.getRect(find.text("Label")).center.dx -
t.getRect(find.byType(Button)).center.dx;
}
Button btn({Widget? leading, Widget? trailing, AlignmentGeometry? alignment}) =>
Button(
style: const ButtonStyle.secondary(),
alignment: alignment,
leading: leading,
trailing: trailing,
onPressed: () {},
child: const Text("Label"),
);
const icon = Icon(LucideIcons.mail);
_fieldCentring();
_labelNeverWraps();
testWidgets("centred label is centred with no icons", (t) async {
expect(await offset(t, btn(alignment: Alignment.center)), 0.0);
});
testWidgets("centred label is centred with a leading icon", (t) async {
expect(await offset(t, btn(alignment: Alignment.center, leading: icon)), 0.0);
});
testWidgets("centred label is centred with a trailing icon", (t) async {
expect(await offset(t, btn(alignment: Alignment.center, trailing: icon)), 0.0);
});
testWidgets("centred label is centred with both", (t) async {
expect(
await offset(t,
btn(alignment: Alignment.center, leading: icon, trailing: icon)),
0.0);
});
testWidgets("no alignment means no balancing - the button still shrink-wraps",
(t) async {
// without an alignment there is no centre to miss, and padding the empty
// side out would only make the button wider.
// unconstrained, so the button reports its own intrinsic width
Widget loose(Widget c) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(child: c),
),
);
await t.pumpWidget(loose(btn(leading: icon)));
final unaligned = t.getSize(find.byType(Button)).width;
await t.pumpWidget(loose(btn(alignment: Alignment.center, leading: icon)));
final aligned = t.getSize(find.byType(Button)).width;
// the balanced one is wider by exactly the mirrored icon plus its gap
expect(aligned, greaterThan(unaligned));
expect(aligned - unaligned,
const Density().iconSize + const Density().controlGap);
});
testWidgets("an icon button with a leading icon isnt balanced out", (t) async {
// there is no label in here to sit off centre - both slots are glyphs - so
// the mirror spacer is 15px of nothing. the snap targets button in the
// blender header measured 52 wide for 38 of content because of it.
Widget loose(Widget c) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(child: c),
),
);
await t.pumpWidget(loose(IconButton.outline(
density: ControlDensity.compact,
leading: Icon(LucideIcons.ruler_dimension_line).iconSmall,
icon: Icon(LucideIcons.chevron_down).iconSmall,
onPressed: () {},
)));
const d = Density();
final pad = ControlDensity.compact
.resolveIcon(ThemeData(colorScheme: testScheme, density: d));
expect(
t.getSize(find.byType(IconButton)).width,
pad.horizontal + d.iconSize * 2 + d.controlGap,
);
});
}
// The same defect in TextField: centred text sits in an Expanded, so a leading
// feature pushed it off the field's centre by half that feature plus the gap.
void _fieldCentring() {
Widget host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(child: SizedBox(width: 300, child: child)),
),
);
testWidgets("centred field text is centred with a leading feature",
(t) async {
await t.pumpWidget(host(const TextField(
initialValue: "abc",
readOnly: true,
enabled: false,
textAlign: TextAlign.center,
features: [InputFeature.leading(Icon(LucideIcons.mail))],
)));
final field = t.getRect(find.byType(TextField));
final text = t.getRect(find.text("abc"));
expect((text.center.dx - field.center.dx).abs(), lessThan(0.5));
});
testWidgets("balancing uses a spacer, not a second icon", (t) async {
// a copy of the feature would sit in the tree twice, which is how this
// broke a test that looked for exactly one icon inside a field.
await t.pumpWidget(host(const TextField(
initialValue: "abc",
readOnly: true,
enabled: false,
textAlign: TextAlign.center,
features: [InputFeature.leading(Icon(LucideIcons.mail))],
)));
expect(
find.descendant(
of: find.byType(TextField), matching: find.byIcon(LucideIcons.mail)),
findsOneWidget,
);
});
}
// A leading icon takes width off the label's allowance; Expanded forces the
// label to absorb it, and Text used to resolve that by wrapping - "Use" on one
// line and the rest on a second the button's height hid. It reads as a
// truncation but never was one.
void _labelNeverWraps() {
Widget host(Widget child, double cap) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(child: SizedBox(width: cap, child: Wrap(children: [child]))),
),
);
for (final cap in [400.0, 130.0, 90.0]) {
testWidgets("label stays on one line at width $cap", (t) async {
await t.pumpWidget(host(
Button(
style: const ButtonStyle.primary(),
leading: const Icon(LucideIcons.mail),
onPressed: () {},
child: const Text("Use passkey"),
),
cap,
));
final rp = t.renderObject(find.text("Use passkey")) as RenderParagraph;
expect(rp.size.height, const Density().lineBox,
reason: "a wrapped label is twice this tall");
});
}
}
+105
View File
@@ -0,0 +1,105 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// A segmented control lives in a PropertyRow: bounded WIDTH, unbounded height.
// That combination is the whole point of these tests - `expands: true` drops
// the IntrinsicHeight that gives `stretch` something to stretch to, and the
// buttons collapse to nothing.
Widget _host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(width: 300, child: child),
),
),
);
Widget _seg(String label) => Button(
style: const ButtonStyle.outline(),
alignment: Alignment.center,
onPressed: () {},
child: Text(label),
);
void main() {
testWidgets("fill splits the main axis evenly", (tester) async {
await tester.pumpWidget(
_host(
ButtonGroup.horizontal(
fill: true,
children: [_seg("Off"), _seg("Optional"), _seg("Required")],
),
),
);
expect(tester.takeException(), isNull);
final off = tester.getSize(find.widgetWithText(Button, "Off"));
final optional = tester.getSize(find.widgetWithText(Button, "Optional"));
final required = tester.getSize(find.widgetWithText(Button, "Required"));
// an even share of the 300, not a share of the label lengths
expect(off.width, closeTo(100, 1));
expect(optional.width, closeTo(100, 1));
expect(required.width, closeTo(100, 1));
// and a real height, which is what collapsed before
expect(off.height, greaterThan(0));
});
testWidgets("it survives a narrow field without overflowing", (
tester,
) async {
// a phone-width property row. Three segments that each want ~90px of
// label in 200px is where a fill would blow the right edge off.
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 200,
child: ButtonGroup.horizontal(
fill: true,
children: [_seg("Off"), _seg("Optional"), _seg("Required")],
),
),
),
),
),
);
expect(tester.takeException(), isNull);
final group = tester.getSize(find.byType(ButtonGroup));
expect(group.width, closeTo(200, 1));
for (final label in ["Off", "Optional", "Required"]) {
final seg = tester.getSize(find.widgetWithText(Button, label));
expect(seg.width, closeTo(200 / 3, 1), reason: label);
expect(seg.height, greaterThan(0), reason: label);
}
});
testWidgets("without fill it still shrink wraps to its labels", (
tester,
) async {
await tester.pumpWidget(
_host(
ButtonGroup.horizontal(
children: [_seg("Off"), _seg("Optional"), _seg("Required")],
),
),
);
expect(tester.takeException(), isNull);
final off = tester.getSize(find.widgetWithText(Button, "Off"));
final required = tester.getSize(find.widgetWithText(Button, "Required"));
expect(off.width, lessThan(required.width));
});
}
@@ -0,0 +1,80 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// ChromeBar's height was a hardcoded 30 that didnt move with the tier. At
// normal that left a 27px control 1.5px of margin, and at product the bar came
// out SHORTER than the 33px buttons meant to sit in it - a header its own
// contents didnt fit. It takes the density's token now.
Future<double> _barHeight(WidgetTester tester, Density density) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: density),
child: const Align(
alignment: Alignment.topCenter,
child: ChromeBar(child: SizedBox.shrink()),
),
),
),
);
await tester.pump();
return tester.getSize(find.byType(ChromeBar)).height;
}
void main() {
for (final density in const [
Density(),
Density.normal(),
Density.product(),
]) {
final label = "${density.controlHeight.toInt()}px controls";
test("a chrome bar clears its own controls ($label)", () {
// one controlGap above and one below - the same air a button gets from
// the button beside it
expect(
density.chromeBarHeight,
density.controlHeight + density.controlGap * 2,
);
// the thing that was actually broken: the bar has to be able to hold
// the control, not merely be near its size
expect(
density.chromeBarHeight,
greaterThan(density.controlHeight),
reason: "a control wouldnt fit inside its own chrome",
);
});
testWidgets("ChromeBar takes that height with none given ($label)", (
tester,
) async {
expect(await _barHeight(tester, density), density.chromeBarHeight);
});
}
testWidgets("an explicit height still wins", (tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(
colorScheme: testScheme,
density: const Density.product(),
),
child: const Align(
alignment: Alignment.topCenter,
child: ChromeBar(height: 52, child: SizedBox.shrink()),
),
),
),
);
await tester.pump();
expect(tester.getSize(find.byType(ChromeBar)).height, 52);
});
}
@@ -0,0 +1,43 @@
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/theme/theme_data.dart";
// The gap scale is derived from controlGap, and the claim that makes that the
// right base is that two tokens which were authored by hand long before the
// scale existed land exactly on steps of it. If someone retunes controlGap,
// containerGap or containerPadding and that stops being true, the base unit
// has drifted and the scale is lying about where it comes from.
void main() {
for (final density in const [Density(), Density.normal()]) {
final name = density.control.name;
test("$name: gapMd is containerGap, gapXl is containerPadding", () {
expect(density.gapMd, density.containerGap);
expect(density.gapXl, density.containerPadding);
});
test("$name: the scale climbs and stays on whole pixels", () {
final steps = [
density.gapXxs,
density.gapXs,
density.gapSm,
density.gapMd,
density.gapLg,
density.gapXl,
density.gapXxl,
];
for (var i = 1; i < steps.length; i++) {
expect(steps[i], greaterThan(steps[i - 1]),
reason: "step $i should be bigger than the one before it");
}
for (final s in steps) {
expect(s, s.roundToDouble(), reason: "$s is not a whole pixel");
}
});
}
test("compact lands on the sizes the style guide observed in real use", () {
const d = Density();
expect([d.gapXxs, d.gapXs, d.gapSm, d.gapMd, d.gapLg, d.gapXl, d.gapXxl],
[2.0, 4.0, 6.0, 8.0, 12.0, 16.0, 24.0]);
});
}
+131
View File
@@ -0,0 +1,131 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
Widget _host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme),
child: Center(child: SizedBox(width: 400, child: child)),
),
);
Widget _row({String? error}) => _host(
SettingsRow(
key: const Key("row"),
label: "Name",
error: error,
field: const TextField(),
),
);
void main() {
testWidgets("the label's aside renders beside it, not instead of it", (
tester,
) async {
await tester.pumpWidget(
_host(
const SettingsRow(
label: "Name",
action: Text("Required"),
field: TextField(),
),
),
);
expect(find.text("Name"), findsOneWidget);
expect(find.text("Required"), findsOneWidget);
// on the label's line, and at the FAR right of its half of the row -
// spaceBetween, the way ProductField does it in the onboarding flow.
final label = tester.getRect(find.text("Name"));
final tag = tester.getRect(find.text("Required"));
final field = tester.getRect(find.byType(TextField));
expect((tag.center.dy - label.center.dy).abs(), lessThan(2));
// tucked against the label rather than pushed to the far end of the
// column - one gap between them, not the whole remaining width.
expect(tag.left - label.right, lessThan(12));
expect(tag.left, greaterThan(label.right - 1));
expect(tag.right, lessThan(field.left));
});
testWidgets("the aside reads as a tag, not as a second label", (
tester,
) async {
await tester.pumpWidget(
_host(
const SettingsRow(
label: "Name",
action: Text("Required"),
field: TextField(),
),
),
);
final tag = tester.widget<Text>(find.text("Required"));
final style = DefaultTextStyle.of(
tester.element(find.text("Required")),
).style.merge(tag.style);
final label = tester.widget<Text>(find.text("Name"));
expect(style.fontSize, const Density().textXxs);
expect(style.color, testScheme.mutedForeground);
expect(style.fontSize, lessThan(label.style!.fontSize!));
});
testWidgets("a property row puts it in the same place", (tester) async {
await tester.pumpWidget(
_host(
PropertyRow(
label: "Price",
scheme: testScheme,
action: const Text("Required"),
child: const TextField(),
),
),
);
final label = tester.getRect(find.text("Price"));
final tag = tester.getRect(find.text("Required"));
final field = tester.getRect(find.byType(TextField));
expect(tag.left - label.right, lessThan(12));
expect(tag.right, lessThan(field.left));
});
testWidgets("the row eases open rather than jumping", (tester) async {
await tester.pumpWidget(_row());
await tester.pumpAndSettle();
final bare = tester.getSize(find.byKey(const Key("row"))).height;
await tester.pumpWidget(_row(error: "nope"));
await tester.pump(); // the frame the change lands on
final atStart = tester.getSize(find.byKey(const Key("row"))).height;
await tester.pump(const Duration(milliseconds: 90));
final midway = tester.getSize(find.byKey(const Key("row"))).height;
await tester.pumpAndSettle();
final settled = tester.getSize(find.byKey(const Key("row"))).height;
expect(settled, greaterThan(bare));
expect(midway, greaterThan(atStart));
expect(midway, lessThan(settled));
});
testWidgets("the outline fades in", (tester) async {
await tester.pumpWidget(_row());
await tester.pumpAndSettle();
await tester.pumpWidget(_row(error: "nope"));
await tester.pump();
await tester.pump(const Duration(milliseconds: 90));
final fades = tester
.widgetList<FadeTransition>(find.byType(FadeTransition))
.map((f) => f.opacity.value)
.toList();
expect(fades.any((v) => v > 0.0 && v < 1.0), isTrue);
});
}
@@ -0,0 +1,112 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// A feature lives INSIDE the field. It never gets to decide how tall the field
// is - that is the density's job and nothing elses.
//
// It used to. The field wrapped its whole content row in textFieldPadding, so
// anything you put in a feature slot got the padding stacked on top of its own
// height. A trailing IconButton is a full control tall (23 at compact), so the
// field came out at 23 + 12 = 35 against a controlHeight of 23 - half again as
// tall as the plain field sat next to it. The packages OWN InputFeature.clear()
// did it too, which is how it survived this long unnoticed.
//
// The existing feature-height probe in the app only ever passed a bare Text, so
// none of this was covered.
Future<double> _height(
WidgetTester tester, {
required Density density,
required List<InputFeature> features,
String text = "",
}) async {
final controller = TextEditingController(text: text);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: density),
child: Center(
child: SizedBox(
width: 300,
child: TextField(controller: controller, features: features),
),
),
),
),
);
await tester.pump();
return tester.getSize(find.byType(TextField)).height;
}
void main() {
for (final density in const [Density(), Density.normal()]) {
final label = density.control.name;
testWidgets("a trailing button doesnt grow the field ($label)", (
tester,
) async {
final plain = await _height(tester, density: density, features: const []);
final withButton = await _height(
tester,
density: density,
features: [
InputFeature.trailing(
IconButton(
variance: ButtonVariance.ghost,
density: ControlDensity.compact,
icon: const Icon(LucideIcons.eye),
onPressed: () {},
),
),
],
);
expect(plain, density.controlHeight);
expect(
withButton,
plain,
reason:
"a button in a trailing slot pushed the field to $withButton "
"against a plain $plain",
);
});
testWidgets("every built-in feature holds controlHeight ($label)", (
tester,
) async {
final cases = <String, List<InputFeature>>{
"leading icon": const [InputFeature.leading(Icon(LucideIcons.lock))],
"clear": [const InputFeature.clear()],
"spinner": [const InputFeature.spinner()],
"increment": [const InputFeature.incrementButton()],
"scrub": [const InputFeature.scrub()],
};
for (final entry in cases.entries) {
// empty AND filled - the clear button only shows up once theres text.
for (final text in const ["", "12"]) {
final h = await _height(
tester,
density: density,
features: entry.value,
text: text,
);
expect(
tester.takeException(),
isNull,
reason: "${entry.key} threw laying out in the field",
);
expect(
h,
density.controlHeight,
reason: "${entry.key} made the field $h",
);
}
}
});
}
}
+63
View File
@@ -0,0 +1,63 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
Widget _host(Density density, Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: density),
child: Column(children: [child]),
),
);
void main() {
for (final density in const [Density(), Density.normal()]) {
testWidgets("${density.control.name}: Gap.md takes its extent from the theme",
(tester) async {
await tester.pumpWidget(_host(density, const Gap.md()));
expect(tester.getSize(find.byType(Gap)).height, density.gapMd);
});
}
testWidgets("a literal Gap still wins over the scale", (tester) async {
await tester.pumpWidget(_host(const Density(), const Gap(17)));
expect(tester.getSize(find.byType(Gap)).height, 17.0);
});
_outsideFlex();
testWidgets("the same Gap.md moves when the density does", (tester) async {
await tester.pumpWidget(_host(const Density(), const Gap.md()));
final compact = tester.getSize(find.byType(Gap)).height;
await tester.pumpWidget(_host(const Density.normal(), const Gap.md()));
final normal = tester.getSize(find.byType(Gap)).height;
expect(normal, greaterThan(compact));
});
}
// Regression: a Gap with no Flex parent used to throw at layout time and take
// the enclosing subtree with it. One inside an AnimatedSize destroyed a login
// card in the Garage hub, on a build that analyzed clean.
void _outsideFlex() {
testWidgets("a Gap outside a Flex lays out instead of throwing",
(tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: const Center(child: SizedBox(width: 100, child: Gap.md())),
),
),
);
// it lays out - that is the whole point, it used to take the subtree down
expect(tester.getSize(find.byType(Gap)).height, const Density().gapMd);
// and it says so loudly in debug rather than failing silently
final reported = tester.takeException();
expect(reported, isA<FlutterError>());
expect("$reported", contains("no axis to size along"));
});
}
@@ -0,0 +1,98 @@
import "dart:ui" show Color;
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/theme/theme_data.dart";
// CHARACTERISATION BASELINE — do not "fix" these numbers.
//
// This locks the geometry Arcs & Angles renders at today, ahead of the density
// API being reshaped. The API is expected to change; the numbers are not. A
// failure here means the refactor moved something on screen, which is the one
// outcome that isn't allowed.
//
// Captured 28 Aug 2026 from Density() and Density.normal().
void main() {
group("compact", () {
const d = Density();
test("type", () {
expect(d.fontSize, 10.0);
expect(d.lineHeight, 1.1);
expect(d.lineBox, 11.0);
// level with the font, not the line box - an icon sized to the line box
// reads heavier than the label beside it
expect(d.iconSize, 10.0);
});
test("text ladder", () {
expect(d.textXs, 11.0);
expect(d.textSm, 14.0);
expect(d.textLg, 18.0);
});
test("control geometry", () {
expect(d.controlHeight, 23.0);
expect(d.controlPaddingY, 6.0);
expect(d.buttonPaddingX, 10.0);
expect(d.controlGap, 4.0);
expect(d.controlBorderWidth, 1.0);
expect(d.buttonPadding.horizontal, 20.0);
expect(d.buttonPadding.vertical, 12.0);
expect(d.borderedControlPadding.horizontal, 18.0);
expect(d.borderedControlPadding.vertical, 10.0);
});
test("rows", () {
expect(d.menuRowHeight, 20.0);
expect(d.popupRowHeight, 20.0);
expect(d.popupMaxHeight, 240.0);
expect(d.explorerRowHeight, 22.0);
expect(d.explorerRowPaddingY, 5.5);
expect(d.explorerRowBasePadding, 4.0);
expect(d.explorerRowEndPadding, 10.0);
expect(d.listRowIndent, 6.0);
});
test("containers and gaps", () {
expect(d.containerGap, 8.0);
expect(d.containerPadding, 16.0);
expect([d.gapXxs, d.gapXs, d.gapSm, d.gapMd, d.gapLg, d.gapXl, d.gapXxl],
[2.0, 4.0, 6.0, 8.0, 12.0, 16.0, 24.0]);
});
});
group("normal", () {
const d = Density.normal();
test("type", () {
expect(d.fontSize, 10.0);
expect(d.lineBox, 11.0);
});
test("text ladder", () {
expect(d.textXs, 11.0);
expect(d.textSm, 14.0);
expect(d.textLg, 18.0);
});
test("control geometry", () {
expect(d.controlHeight, 27.0);
expect(d.controlPaddingY, 8.0);
expect(d.buttonPaddingX, 12.0);
expect(d.controlGap, 5.0);
});
test("rows", () {
expect(d.menuRowHeight, 26.0);
expect(d.popupRowHeight, 26.0);
expect(d.explorerRowHeight, 30.0);
expect(d.explorerRowBasePadding, 8.0);
expect(d.explorerRowEndPadding, 14.0);
expect(d.listRowIndent, 12.0);
});
test("containers and gaps", () {
expect(d.containerGap, 10.0);
expect(d.containerPadding, 20.0);
expect([d.gapXxs, d.gapXs, d.gapSm, d.gapMd, d.gapLg, d.gapXl, d.gapXxl],
[3.0, 5.0, 8.0, 10.0, 15.0, 20.0, 30.0]);
});
});
test("icon tiers at scaling 1.0", () {
final t = IconThemeTokens.forDensity(const Density(), 1.0, const Color(0));
expect(t.small.size, 10.0);
expect(t.medium.size, 20.0);
expect(t.large.size, 24.0);
});
}
+121
View File
@@ -0,0 +1,121 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// `.iconSmall` and friends set a SIZE. They used to set the tier's colour too -
// the global scheme foreground - which beat whatever IconTheme enclosed the
// icon. Inside a button that meant the button's variant colour lost: a primary
// button hands its leading icon primaryForeground, and the wrapper put
// scheme.foreground back over it. On a dark scheme thats near-white on a
// near-white fill, so the icon vanished.
Color? _resolved(WidgetTester tester, Key key) {
final finder = find.descendant(
of: find.byKey(key),
matching: find.byType(Icon),
);
final element = finder.evaluate().first;
final icon = element.widget as Icon;
return icon.color ?? IconTheme.of(element).color;
}
void main() {
testWidgets("a size helper doesnt take the button's colour off its icon", (
tester,
) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Button(
key: const Key("bare"),
style: const ButtonStyle.primary(),
leading: const Icon(LucideIcons.user),
onPressed: () {},
child: const Text("bare"),
),
Button(
key: const Key("sized"),
style: const ButtonStyle.primary(),
leading: const Icon(LucideIcons.user).iconSmall,
onPressed: () {},
child: const Text("sized"),
),
],
),
),
),
),
);
await tester.pump();
expect(
_resolved(tester, const Key("sized")),
_resolved(tester, const Key("bare")),
reason: "wrapping in .iconSmall should only change the size",
);
expect(
_resolved(tester, const Key("sized")),
testScheme.primaryForeground,
reason: "a primary button's icon comes off primaryForeground",
);
});
testWidgets("outside a button it still falls through to the app theme", (
tester,
) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(
child: KeyedSubtree(
key: const Key("loose"),
child: const Icon(LucideIcons.user).iconSmall,
),
),
),
),
);
await tester.pump();
expect(_resolved(tester, const Key("loose")), testScheme.foreground);
});
testWidgets("the size still comes off the density", (tester) async {
for (final density in const [Density(), Density.normal()]) {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: density),
child: Center(
child: KeyedSubtree(
key: const Key("sized"),
child: const Icon(LucideIcons.user).iconSmall,
),
),
),
),
);
await tester.pump();
final element = find
.descendant(
of: find.byKey(const Key("sized")),
matching: find.byType(Icon),
)
.evaluate()
.first;
expect(IconTheme.of(element).size, density.iconSize);
}
});
}
+163
View File
@@ -0,0 +1,163 @@
import "package:flutter/services.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
/// Type [text] one character at a time through [f], the way a keyboard would.
///
/// Feeding the whole string in one go would only ever test paste - and a mask
/// that handles paste can still put the caret in the wrong place on every
/// keystroke, which is the bug people actually hit.
TextEditingValue _type(MaskedTextInputFormatter f, String text) {
var v = TextEditingValue.empty;
for (final ch in text.split("")) {
final at = v.selection.baseOffset < 0
? v.text.length
: v.selection.baseOffset;
final next = TextEditingValue(
text: v.text.substring(0, at) + ch + v.text.substring(at),
selection: TextSelection.collapsed(offset: at + 1),
);
v = f.formatEditUpdate(v, next);
}
return v;
}
/// One backspace at the caret.
TextEditingValue _backspace(MaskedTextInputFormatter f, TextEditingValue v) {
final at = v.selection.baseOffset;
if (at <= 0) return v;
return f.formatEditUpdate(
v,
TextEditingValue(
text: v.text.substring(0, at - 1) + v.text.substring(at),
selection: TextSelection.collapsed(offset: at - 1),
),
);
}
TextEditingValue _paste(MaskedTextInputFormatter f, String text) =>
f.formatEditUpdate(
TextEditingValue.empty,
TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
),
);
void main() {
final phone = MaskedTextInputFormatter("### ###-####");
group("typing", () {
test("literals arrive on their own", () {
expect(_type(phone, "5").text, "5");
expect(_type(phone, "555").text, "555");
// the space appears with the 4th digit, not before it - a trailing
// literal you cant delete reads as the field being stuck
expect(_type(phone, "5551").text, "555 1");
expect(_type(phone, "555123").text, "555 123");
expect(_type(phone, "5551234").text, "555 123-4");
expect(_type(phone, "5551234567").text, "555 123-4567");
});
test("the caret stays after what you typed", () {
final v = _type(phone, "5551");
expect(v.text, "555 1");
expect(v.selection.baseOffset, 5);
});
test("it stops when the mask is full", () {
expect(_type(phone, "55512345678901").text, "555 123-4567");
});
test("characters the slot wont take are dropped, not the whole edit", () {
expect(_type(phone, "5a5b5c").text, "555");
});
});
group("deleting", () {
test("backspace over a literal still removes a character", () {
// the case a naive mask gets stuck on: the caret is just past the space,
// backspace kills the space, the digits are unchanged, and re-applying
// the mask puts the space straight back.
var v = _type(phone, "5551");
expect(v.text, "555 1");
v = _backspace(phone, v);
expect(v.text, "555");
v = _backspace(phone, v);
expect(v.text, "55");
});
test("backspacing the lot empties the field", () {
var v = _type(phone, "5551234567");
for (var i = 0; i < 20; i++) {
v = _backspace(phone, v);
}
expect(v.text, "");
});
test("backspace at the start is a no-op, not a crash", () {
final v = phone.formatEditUpdate(
TextEditingValue.empty,
TextEditingValue.empty,
);
expect(v.text, "");
expect(_backspace(phone, v).text, "");
});
});
group("pasting", () {
test("an already formatted number survives", () {
expect(_paste(phone, "555 123-4567").text, "555 123-4567");
});
test("someone elses formatting is reduced and re-laid", () {
expect(_paste(phone, "(555) 123-4567").text, "555 123-4567");
expect(_paste(phone, "555.123.4567").text, "555 123-4567");
expect(_paste(phone, "5551234567").text, "555 123-4567");
});
test("overlong paste is cut to the mask", () {
expect(_paste(phone, "555123456789").text, "555 123-4567");
});
});
group("slot kinds", () {
test("A takes letters, # takes digits", () {
final post = MaskedTextInputFormatter("AA# #AA");
expect(_type(post, "SW1A1AA").text, "SW1 1AA");
// a digit cant fill a letter slot
expect(_type(post, "12").text, "");
});
test("* takes either", () {
final any = MaskedTextInputFormatter("**-**");
expect(_type(any, "a1b2").text, "a1-b2");
});
});
group("unmask", () {
test("gives back only what was typed", () {
expect(phone.unmask("555 123-4567"), "5551234567");
expect(phone.unmask("555 1"), "5551");
expect(phone.unmask(""), "");
});
test("a literal that looks like a slot character isnt returned", () {
// the leading 1 is part of the mask, not something anybody typed
final trunk = MaskedTextInputFormatter("1-###-####");
final v = _type(trunk, "5551234");
expect(v.text, "1-555-1234");
expect(trunk.unmask(v.text), "5551234");
});
test("round trips through apply", () {
const raw = "5551234567";
expect(phone.unmask(phone.apply(raw)), raw);
});
});
test("slotCount counts slots, not mask length", () {
expect(phone.slotCount, 10);
expect(phone.mask.length, 12);
});
}
@@ -0,0 +1,149 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// A group inside a group. The inner one used to install its own scope over the
// outer one's, so the outer's say was simply lost: the bottom row's field kept
// the top corners the vertical group had flattened, and painted a top edge the
// row above had already drawn.
//
// Corners are 0-or-1 keep factors, so composing is a product - a corner stays
// round only where BOTH groups leave it alone.
void main() {
ButtonGroupCorners cornersOf(WidgetTester tester, Type type) {
final ctx = tester.element(find.byType(type));
return ButtonGroupScope.maybeOf(ctx)!.corners;
}
ButtonGroupScope scopeOf(WidgetTester tester, Key key) {
return ButtonGroupScope.maybeOf(tester.element(find.byKey(key)))!;
}
testWidgets("a horizontal group nested in a vertical one composes", (
tester,
) async {
const top = Key("top");
const bottomStart = Key("bottom-start");
const bottomEnd = Key("bottom-end");
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(
child: SizedBox(
width: 300,
child: ButtonGroup.vertical(
expands: true,
children: [
const SizedBox(key: top, height: 20),
// no expands on the inner group - it keeps the
// IntrinsicHeight that bounds it inside the vertical one.
ButtonGroup.horizontal(
children: const [
Expanded(child: SizedBox(key: bottomStart, height: 20)),
SizedBox(key: bottomEnd, height: 20, width: 20),
],
),
],
),
),
),
),
),
);
// top row: vertical index 0 - both top corners live, bottom flattened
// against the row below.
final t = scopeOf(tester, top);
expect(
[t.corners.topStart, t.corners.topEnd],
[1, 1],
reason: "the top row keeps its top corners",
);
expect(
[t.corners.bottomStart, t.corners.bottomEnd],
[0, 0],
reason: "the top row's bottom is a seam",
);
expect(t.hideTop, isFalse);
expect(t.hideStart, isFalse);
// bottom-left: bottom of the vertical group AND start of the horizontal
// one, so the ONLY surviving corner is bottom-start.
final bs = scopeOf(tester, bottomStart);
expect(
[
bs.corners.topStart,
bs.corners.topEnd,
bs.corners.bottomStart,
bs.corners.bottomEnd,
],
[0, 0, 1, 0],
reason: "only the outer bottom-start corner survives both groups",
);
expect(
bs.hideTop,
isTrue,
reason: "the row above already painted this edge",
);
expect(bs.hideStart, isFalse, reason: "its the first in its own row");
// bottom-right: bottom of the vertical AND end of the horizontal, so only
// bottom-end survives - and it gives up BOTH its top and its start edge,
// which is the pair a single axis+index could never express.
final be = scopeOf(tester, bottomEnd);
expect(
[
be.corners.topStart,
be.corners.topEnd,
be.corners.bottomStart,
be.corners.bottomEnd,
],
[0, 0, 0, 1],
);
expect(be.hideTop, isTrue, reason: "the row above painted the top edge");
expect(
be.hideStart,
isTrue,
reason: "the field beside it painted the start edge",
);
});
testWidgets("an unnested group is unchanged", (tester) async {
const a = Key("a");
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(
child: ButtonGroup.horizontal(
children: const [
SizedBox(key: a, height: 20, width: 20),
SizedBox(height: 20, width: 20),
],
),
),
),
),
);
final s = scopeOf(tester, a);
expect(
[
s.corners.topStart,
s.corners.topEnd,
s.corners.bottomStart,
s.corners.bottomEnd,
],
[1, 0, 1, 0],
);
expect(s.hideTop, isFalse);
expect(s.hideStart, isFalse);
});
}
+167
View File
@@ -0,0 +1,167 @@
import "package:flutter/widgets.dart";
import "package:flutter_lucide/flutter_lucide.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
Widget _host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
// WidgetsLocalizations, which ReorderableList wants for its drag
// semantics. A WidgetsApp brings them; this host isnt one, so it says
// so itself rather than the widget quietly shadowing an app's locale.
child: Localizations(
locale: const Locale("en", "US"),
delegates: const [DefaultWidgetsLocalizations.delegate],
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
// deliberately NO Material and no MaterialApp: garage's app shell is
// a WidgetsApp, so anything material PropertiesList needs it has to
// bring itself. The Overlay is the one thing a WidgetsApp really does
// provide (through its Navigator), so the host provides it too.
child: Overlay(initialEntries: [OverlayEntry(builder: (_) => child)]),
),
),
);
PropertiesEntry _entry(String id, {PropertiesPin? pin, bool movable = true}) {
return PropertiesEntry(
id: id,
pin: pin,
movable: movable,
child: Builder(
builder: (context) => PropertiesSection(
title: id,
scheme: GarageTheme.of(context).colorScheme,
collapsed: true,
onToggle: () {},
rows: const [],
),
),
);
}
void main() {
testWidgets("every section is movable by default", (tester) async {
await tester.pumpWidget(
_host(
PropertiesList(entries: [_entry("a"), _entry("b")], onReorder: (_) {}),
),
);
// one grab handle each - the default is that things move
expect(find.byIcon(LucideIcons.grip_horizontal), findsNWidgets(2));
});
testWidgets("an unmovable section has no handle", (tester) async {
await tester.pumpWidget(
_host(
PropertiesList(
entries: [_entry("a"), _entry("b", movable: false)],
onReorder: (_) {},
),
),
);
expect(find.byIcon(LucideIcons.grip_horizontal), findsOneWidget);
});
testWidgets("a pinned section sits above the list, out of its reach", (
tester,
) async {
await tester.pumpWidget(
_host(
PropertiesList(
// handed over LAST, and it still comes out first
entries: [
_entry("a"),
_entry("b"),
_entry("top", pin: PropertiesPin.top),
],
onReorder: (_) {},
),
),
);
final top = tester.getTopLeft(find.text("top")).dy;
expect(top, lessThan(tester.getTopLeft(find.text("a")).dy));
// two movable, so two handles - the pinned one has none
expect(find.byIcon(LucideIcons.grip_horizontal), findsNWidgets(2));
// and it isnt IN the reorderable list at all, which is what stops the
// list shifting it out of the way and then bouncing the drop back.
expect(
find.descendant(
of: find.byType(ReorderableList),
matching: find.text("top"),
),
findsNothing,
);
expect(
find.descendant(
of: find.byType(ReorderableList),
matching: find.text("a"),
),
findsOneWidget,
);
});
testWidgets("with nothing pinned there is no stray list", (tester) async {
await tester.pumpWidget(
_host(
PropertiesList(entries: [_entry("a"), _entry("b")], onReorder: (_) {}),
),
);
expect(find.byType(ReorderableList), findsOneWidget);
});
test("a section is not rebuilt just because its position moved", () {
// the whole reason the index lives in a mutable slot. Notifying on it
// rebuilt every section in the list each time the gap shifted mid-drag -
// rows, text fields and all - which is what made dragging feel like it
// was catching.
final slot = PropertiesReorderSlot(0);
const child = SizedBox();
final before = PropertiesReorderScope(
slot: slot,
draggable: true,
child: child,
);
slot.index = 3;
final after = PropertiesReorderScope(
slot: slot,
draggable: true,
child: child,
);
expect(after.updateShouldNotify(before), isFalse);
// but whether it can be dragged at all still is worth a rebuild - thats
// the handle appearing or going.
final locked = PropertiesReorderScope(
slot: slot,
draggable: false,
child: child,
);
expect(locked.updateShouldNotify(after), isTrue);
});
testWidgets("a section on its own has no handle at all", (tester) async {
await tester.pumpWidget(
_host(
Builder(
builder: (context) => PropertiesSection(
title: "alone",
scheme: GarageTheme.of(context).colorScheme,
collapsed: true,
onToggle: () {},
rows: const [],
),
),
),
);
expect(find.byIcon(LucideIcons.grip_horizontal), findsNothing);
});
}
@@ -0,0 +1,131 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// The actions band. Before this existed a Save button had to go in `rows`,
// where it read as one more property that happened to be a button - no
// separator, no tone, and lined up against the property split like a value.
Widget _host(Widget child, {Density density = const Density()}) =>
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: density),
child: Center(child: SizedBox(width: 420, child: child)),
),
);
Widget _section({List<Widget> actions = const [], bool collapsed = false}) =>
PropertiesSection(
title: "Profile",
scheme: testScheme,
collapsed: collapsed,
onToggle: () {},
actions: actions,
rows: [
PropertyRow(
label: "Username",
scheme: testScheme,
child: const SizedBox(),
),
],
);
Color? _bandColour(WidgetTester tester) {
final containers = find
.ancestor(
of: find.text("Save"),
matching: find.byType(Container),
)
.evaluate()
.map((e) => e.widget as Container)
.where((c) => c.color != null);
return containers.isEmpty ? null : containers.first.color;
}
void main() {
testWidgets("no actions means no band and no separator", (tester) async {
await tester.pumpWidget(_host(_section()));
expect(find.byType(Divider), findsNothing);
});
testWidgets("actions get a separator and the muted band", (tester) async {
await tester.pumpWidget(
_host(
_section(
actions: [
Button(
style: const ButtonStyle.primary(),
onPressed: () {},
child: const Text("Save"),
),
],
),
),
);
expect(find.byType(Divider), findsOneWidget);
// toned OFF the section, not the same fill
expect(_bandColour(tester), testScheme.muted);
expect(_bandColour(tester),
isNot(testScheme.card));
});
testWidgets("the band spans the section, not just its buttons", (
tester,
) async {
await tester.pumpWidget(
_host(
_section(
actions: [
Button(
style: const ButtonStyle.primary(),
onPressed: () {},
child: const Text("Save"),
),
],
),
),
);
const density = Density();
final content = tester.getRect(
find
.ancestor(of: find.text("Save"), matching: find.byType(Row))
.first,
);
final divider = tester.getRect(find.byType(Divider));
// the BAND spans the section edge to edge - its content is inset by the
// same buttonPaddingX a row uses, so the buttons line up with the controls
// above them rather than sitting on the section's edge.
expect(content.width, divider.width - density.buttonPaddingX * 2);
// and the whole thing sits under the rows
expect(
content.top,
greaterThan(tester.getRect(find.text("Username")).bottom),
);
});
testWidgets("actions collapse with the rows", (tester) async {
await tester.pumpWidget(
_host(
_section(
collapsed: true,
actions: [
Button(
style: const ButtonStyle.primary(),
onPressed: () {},
child: const Text("Save"),
),
],
),
),
);
expect(find.text("Save"), findsNothing);
expect(find.byType(Divider), findsNothing);
});
}
@@ -0,0 +1,67 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// The section's own subtitle. Sits in the header under the title, and stays
// there while the section is collapsed - it names the section rather than
// being part of its body. Sections deliberately have no `description`; a box
// of copy above the rows competed with them, and a row's own description
// covers the case anyway.
Widget _host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(child: SizedBox(width: 420, child: child)),
),
);
Widget _section({String? subtitle, bool collapsed = false}) =>
PropertiesSection(
title: "Profile",
subtitle: subtitle,
scheme: testScheme,
collapsed: collapsed,
onToggle: () {},
rows: [
PropertyRow(
label: "Username",
scheme: testScheme,
child: const TextField(),
),
],
);
void main() {
testWidgets("subtitle sits under the title, above the rows", (tester) async {
await tester.pumpWidget(_host(_section(subtitle: "a subtitle")));
final title = tester.getRect(find.text("Profile"));
final sub = tester.getRect(find.text("a subtitle"));
final row = tester.getRect(find.text("Username"));
expect(sub.top, greaterThanOrEqualTo(title.bottom));
expect(sub.left, title.left); // same column, clear of the chevron
expect(row.top, greaterThan(sub.bottom));
});
testWidgets("subtitle renders at textXxs", (tester) async {
await tester.pumpWidget(_host(_section(subtitle: "a subtitle")));
expect(
tester.widget<Text>(find.text("a subtitle")).style!.fontSize,
const Density().textXxs,
);
});
testWidgets("subtitle survives collapse", (tester) async {
await tester.pumpWidget(
_host(_section(subtitle: "a subtitle", collapsed: true)),
);
// it names the section, so it stays when the body goes
expect(find.text("a subtitle"), findsOneWidget);
expect(find.text("Username"), findsNothing);
});
}
+187
View File
@@ -0,0 +1,187 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// A property row used to be label + control and nothing else, so a page
// needing to explain a setting had to either fold it into the label or give up
// and hardcode a Text somewhere. Two slots now:
//
// subtitle - inside the label column, right aligned against the split
// description - full width under the whole row
//
// Both render at density.textXxs, which is the rung below the control font -
// it didnt exist before either, because in A&A the control font is already the
// smallest thing on screen.
Widget _host(Widget child, {Density density = const Density()}) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: density),
child: Center(child: SizedBox(width: 400, child: child)),
),
);
double _fontOf(WidgetTester tester, String text) {
final widget = tester.widget<Text>(find.text(text));
return widget.style!.fontSize!;
}
void main() {
testWidgets("subtitle and description both render at textXxs", (
tester,
) async {
await tester.pumpWidget(
_host(
PropertyRow(
label: "Username",
subtitle: "Display name used across dashboard",
description: "Changing this breaks any links to your old name.",
scheme: testScheme,
child: const SizedBox(),
),
),
);
const density = Density();
expect(density.textXxs, 9.0); // 10 * 0.875, rounded
expect(_fontOf(tester, "Display name used across dashboard"), 9.0);
expect(_fontOf(tester, "Changing this breaks any links to your old name."),
9.0);
});
testWidgets("both slots track the density", (tester) async {
await tester.pumpWidget(
_host(
PropertyRow(
label: "Username",
subtitle: "a subtitle",
scheme: testScheme,
child: const SizedBox(),
),
density: const Density(fontSize: 16.0, lineHeight: 1.25),
),
);
expect(_fontOf(tester, "a subtitle"), 14.0); // 16 * 0.875
});
testWidgets("a description makes the row taller, not the control shorter", (
tester,
) async {
Future<double> heightWith({String? description}) async {
await tester.pumpWidget(
_host(
PropertyRow(
key: const Key("row"),
label: "Username",
description: description,
scheme: testScheme,
child: const SizedBox(),
),
),
);
// the row eases between heights now, so the frame straight after the
// pump is still the old one.
await tester.pumpAndSettle();
return tester.getSize(find.byKey(const Key("row"))).height;
}
final bare = await heightWith();
final withCopy = await heightWith(description: "some copy");
// the bare row is the row minimum - controlHeight plus a step, so that
// neither the control nor the label column is the thing setting it. a
// description adds on top of that rather than squeezing into it.
expect(bare, const Density().propertyRowHeight);
expect(withCopy, greaterThan(bare));
});
testWidgets("the gap between section rows is gapXs, off the density", (
tester,
) async {
// the numbers PropertiesSection used to hardcode - 4 between rows, 8 under
// the last - are exactly gapXs and gapMd at compact, which is the tell they
// were always meant to be these tokens. at normal they should now move.
const compact = Density();
const normal = Density.normal();
expect(compact.gapXs, 4.0);
expect(normal.gapXs, 5.0);
Future<double> gapBetweenRows(Density density) async {
await tester.pumpWidget(
_host(
PropertiesSection(
title: "Profile",
scheme: testScheme,
collapsed: false,
onToggle: () {},
rows: [
for (var i = 0; i < 2; i++)
PropertyRow(
key: Key("row$i"),
label: "Row $i",
scheme: testScheme,
child: const SizedBox(),
),
],
),
density: density,
),
);
final first = tester.getRect(find.byKey(const Key("row0")));
final second = tester.getRect(find.byKey(const Key("row1")));
return second.top - first.bottom;
}
expect(await gapBetweenRows(compact), compact.gapXs);
expect(await gapBetweenRows(normal), normal.gapXs);
});
testWidgets("a subtitle doesnt change the row height", (tester) async {
// the whole reason the minimum sits above controlHeight. a label plus a
// subtitle is laid out on the font's own metrics; at plain controlHeight
// that made the LABEL COLUMN set the row height on some fonts and not
// others. both rows have to come out the same.
Future<double> h({String? subtitle}) async {
await tester.pumpWidget(
_host(
PropertyRow(
key: const Key("row"),
label: "Username",
subtitle: subtitle,
scheme: testScheme,
child: const TextField(),
),
),
);
// the row eases between heights now, so the frame straight after the
// pump is still the old one.
await tester.pumpAndSettle();
return tester.getSize(find.byKey(const Key("row"))).height;
}
expect(await h(subtitle: "a subtitle"), await h());
});
test("the row minimum clears whichever of the two is taller", () {
// at compact the LABEL COLUMN is the binding one - 26 against a 23px
// control - which is the whole reason this token exists. at the roomier
// tiers the control wins again. either way the row clears both, so a
// subtitle never changes the height.
const compact = Density();
expect(compact.labelColumnHeight, 26.0);
expect(compact.controlHeight, 23.0);
expect(compact.propertyRowHeight, 28.0);
const normal = Density.normal();
expect(normal.labelColumnHeight, 26.0);
expect(normal.propertyRowHeight, normal.controlHeight + normal.gapXxs);
for (final d in [compact, normal]) {
expect(d.propertyRowHeight, greaterThan(d.labelColumnHeight));
expect(d.propertyRowHeight, greaterThan(d.controlHeight));
}
});
}
@@ -0,0 +1,111 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// A property section is a docked surface, so its fields are always secondary -
// an outline field in there draws its own border and background against a
// panel that already has both, and reads as floating. That made `variant:` a
// parameter whose every value but one was a bug, so PropertyRow now publishes
// PropertySlotScope and the controls resolve against it.
//
// Explicit does NOT win here, unlike the label scope one widget up. These
// check exactly that.
// the fill a control actually painted, which is the thing the variant decides.
Color? _fill(WidgetTester tester, Key key) {
final box = find
.descendant(of: find.byKey(key), matching: find.byType(DecoratedBox))
.evaluate()
.map((e) => e.widget as DecoratedBox)
.map((d) => d.decoration)
.whereType<BoxDecoration>()
.firstWhere((d) => d.color != null);
return box.color;
}
Widget _host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(child: SizedBox(width: 400, child: child)),
),
);
void main() {
testWidgets("a field in a property row is secondary however its asked", (
tester,
) async {
await tester.pumpWidget(
_host(
Column(
mainAxisSize: MainAxisSize.min,
children: [
PropertyRow(
label: "In a row",
scheme: testScheme,
// asking for outline, explicitly, and losing
child: const TextField(
key: Key("row"),
variant: TextFieldVariant.outline,
),
),
const TextField(
key: Key("bare"),
variant: TextFieldVariant.outline,
),
],
),
),
);
expect(_fill(tester, const Key("row")), testScheme.secondary);
expect(_fill(tester, const Key("bare")), isNot(testScheme.secondary));
});
testWidgets("a labelless row still counts as a slot", (tester) async {
await tester.pumpWidget(
_host(
PropertyRow(
label: "Hidden",
scheme: testScheme,
labelless: true,
child: const TextField(
key: Key("row"),
variant: TextFieldVariant.outline,
),
),
),
);
expect(_fill(tester, const Key("row")), testScheme.secondary);
});
testWidgets("a select in a property row is secondary too", (tester) async {
await tester.pumpWidget(
_host(
PropertyRow(
label: "Role",
scheme: testScheme,
child: Select<String>(
key: const Key("row"),
value: "Owner",
variant: SelectVariant.outline,
itemBuilder: (ctx, item) => Text(item),
onChanged: (_) {},
popup: const SelectPopup(
items: SelectItemList(
children: [
SelectItemButton<String>(value: "Owner", child: Text("Owner")),
],
),
),
),
),
),
);
expect(_fill(tester, const Key("row")), testScheme.secondary);
});
}
+182
View File
@@ -0,0 +1,182 @@
import "dart:math" as math;
import "dart:ui" show Color;
import "package:flutter/widgets.dart" show Brightness;
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/theme/colour_scheme.dart";
import "package:garage_ui/theme/schemes.dart";
// ColourScheme.derive exists so a new app doesn't pay a 37-colour entry fee.
//
// It used to be a table of HSL offsets and these tests could only say "near
// enough". It's four measured laws now, taken off the ten hand authored
// schemes, so most of these check the law rather than the number - and at
// zinc's ground the numbers come out exact, which is worth pinning.
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;
}
void main() {
// the core of Arcs & Angles' zinc, which the laws were measured against
ColourScheme zincish() => ColourScheme.derive(
brightness: Brightness.dark,
background: const Color(0xff303030),
foreground: const Color(0xffe6e6e6),
primary: const Color(0xff4772b3),
);
// carbon's ground. 3.6 L*, so almost no room underneath - this is the one
// that used to come apart.
ColourScheme carbonish() => ColourScheme.derive(
brightness: Brightness.dark,
background: const Color(0xff0d0d0d),
foreground: const Color(0xfff2f2f2),
primary: const Color(0xffe8e8e8),
);
test("four colours produce a complete scheme", () {
final s = zincish();
expect(s.chrome, isNotNull);
expect(s.panelBorderHighlighted, isNotNull);
expect(s.rowHovered, isNotNull);
expect(s.controlBorder, isNotNull);
});
test("reproduces zinc, the scheme it was measured from", () {
final s = zincish();
// not "near" - these are the hand authored values exactly. The laws were
// read off this scheme, so anything else means a law drifted.
expect(s.chrome, const Color(0xff161616));
expect(s.card, const Color(0xff3d3d3d));
expect(s.popover, const Color(0xff181818));
expect(s.muted, const Color(0xff1d1d1d));
expect(s.surfaceSunken, const Color(0xff282828));
expect(s.mutedForeground, const Color(0xff999999));
});
// ── law 2: recessed rungs squeeze into whatever basement there is ───────
test("a ground with no basement squeezes rather than reflecting", () {
final s = carbonish();
final ground = lstar(s.background);
// the old up() reflected an offset that ran off the end, so a slot asked
// to go 12 points DOWN came out 12 points UP - lighter than the surface
// it belongs under. Every recessed slot must still be at or below ground.
for (final (name, colour) in [
("chrome", s.chrome),
("popover", s.popover),
("muted", s.muted),
("controlFill", s.controlFill),
("surfaceSunken", s.surfaceSunken),
]) {
expect(
lstar(colour),
lessThanOrEqualTo(ground + 0.01),
reason: "$name must not surface above the ground it recesses from",
);
}
});
test("squeezing keeps the order of the recessed rungs", () {
final s = carbonish();
// shallowest to deepest, which is the thing reflection destroyed
expect(lstar(s.surfaceSunken), greaterThan(lstar(s.controlFill)));
expect(lstar(s.controlFill), greaterThan(lstar(s.popover)));
expect(lstar(s.popover), greaterThanOrEqualTo(lstar(s.chrome)));
});
// ── law 3: a stroke is measured off the surface it outlines ─────────────
test("strokes sit off their own surface, not off the ground", () {
for (final s in [zincish(), carbonish(), GarageSchemes.light]) {
expect(
lstar(s.popoverBorder) - lstar(s.popover),
closeTo(9.9, 0.6),
reason: "popoverBorder is measured off popover",
);
expect(
lstar(s.controlBorder) - lstar(s.controlFill),
closeTo(11.8, 0.6),
reason: "controlBorder is measured off controlFill",
);
expect(
lstar(s.propertiesSectionBorder) - lstar(s.card),
closeTo(6.2, 0.6),
reason: "a section border outlines card, which is what fills one",
);
}
});
test("a border always reads against the surface it outlines", () {
// the bug this replaces: carbon's popover was pinned to black and its
// border derived DARKER still, so the popover had no visible edge.
for (final s in [zincish(), carbonish(), GarageSchemes.light]) {
expect((lstar(s.popoverBorder) - lstar(s.popover)).abs(), greaterThan(3));
expect(
(lstar(s.controlBorder) - lstar(s.controlFill)).abs(),
greaterThan(3),
);
}
});
// ── law 4: text is a fraction of the span, not an absolute shift ────────
test("muted text sits at the same fraction of the span on any ground", () {
for (final s in [zincish(), carbonish()]) {
final ground = lstar(s.background);
final span = lstar(s.foreground) - ground;
expect(
(lstar(s.mutedForeground) - ground) / span,
closeTo(0.607, 0.02),
reason: "zinc and carbon both measure 60.7%, on spans of 71 and 92",
);
expect((lstar(s.rowText) - ground) / span, closeTo(0.814, 0.02));
}
});
test("light schemes travel the other way", () {
final light = GarageSchemes.light;
expect(lstar(light.chrome), lessThan(lstar(light.background)));
expect(lstar(zincish().chrome), lessThan(lstar(zincish().background)));
// and a light ground has basement to spare, so nothing squeezes
expect(
lstar(light.background) - lstar(light.chrome),
closeTo(12.6, 0.6),
);
});
test("every derived slot has an override", () {
final s = ColourScheme.derive(
brightness: Brightness.dark,
background: const Color(0xff000000),
foreground: const Color(0xffffffff),
primary: const Color(0xff00ff00),
chrome: const Color(0xff123456),
card: const Color(0xff654321),
);
expect(s.chrome, const Color(0xff123456));
expect(s.card, const Color(0xff654321));
// and the override propagates to the slots measured from it
expect(
lstar(s.propertiesSectionBorder) - lstar(const Color(0xff654321)),
closeTo(6.2, 0.6),
);
});
test("GarageSchemes ship a usable pair", () {
for (final s in [GarageSchemes.dark, GarageSchemes.light]) {
expect(s.chrome, isNotNull);
expect(
(lstar(s.foreground) - lstar(s.background)).abs(),
greaterThan(50),
reason: "foreground must contrast with background",
);
}
expect(GarageSchemes.of(Brightness.dark), same(GarageSchemes.dark));
expect(GarageSchemes.of(Brightness.light), same(GarageSchemes.light));
});
}
+95
View File
@@ -0,0 +1,95 @@
import "package:flutter/foundation.dart";
import "package:flutter/services.dart";
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "package:garage_ui/platform/cursor_lock.dart";
import "support/test_scheme.dart";
// Drag-to-scrub used to be macOS only without anybody saying so. The moment
// the drag threshold was crossed the field handed everything over to
// CursorLock and ignored Flutters own pointer stream forever after - and
// CursorLock has no native handler off macOS, so on web (and windows/linux)
// the deltas it was waiting on never turned up. The field just froze on the
// value it started on.
Future<TextEditingController> pumpField(
WidgetTester tester, {
String text = "10.0",
}) async {
final controller = TextEditingController(text: text);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme),
child: Center(
child: SizedBox(
width: 200,
child: TextField(
controller: controller,
features: const [InputFeature.scrub()],
),
),
),
),
),
);
await tester.pump();
return controller;
}
void main() {
testWidgets("no native cursor lock - the pointer stream drives the scrub", (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
expect(CursorLock.isSupported, isFalse);
final controller = await pumpField(tester);
// real pointer moves arrive a few px at a time, so drive it that way
// rather than as one giant jump. default sensitivity is 0.1/px, and the
// first move only arms the drag (it's the one the click threshold eats),
// so 100 counted px = +10.
final gesture = await tester.startGesture(
tester.getCenter(find.byType(TextField)),
);
for (var i = 0; i < 21; i++) {
await gesture.moveBy(const Offset(5, 0));
await tester.pump();
}
await gesture.up();
await tester.pump();
expect(double.parse(controller.text), closeTo(20.0, 0.5));
debugDefaultTargetPlatformOverride = null;
});
testWidgets("with a native cursor lock the pointer stream is ignored", (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.macOS;
const channel = MethodChannel("garage/cursor_lock");
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (_) async => null);
final controller = await pumpField(tester);
final gesture = await tester.startGesture(
tester.getCenter(find.byType(TextField)),
);
for (var i = 0; i < 21; i++) {
await gesture.moveBy(const Offset(5, 0));
await tester.pump();
}
await gesture.up();
await tester.pump();
// the native side would have sent deltas back over the channel; nothing
// did here, so the value must not have budged.
expect(double.parse(controller.text), closeTo(10.0, 1e-9));
debugDefaultTargetPlatformOverride = null;
});
}
@@ -0,0 +1,141 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// the rows sat flush against each other and the surface took the same radius
// they do, so the corner gap didnt match the gap down the sides.
const _density = Density();
Widget _host() => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: _density),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 600,
height: 400,
child: Overlay(
initialEntries: [
OverlayEntry(
builder: (_) => Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 160,
child: Select<String>(
value: "normal",
itemBuilder: (ctx, v) => const Text("Normal"),
onChanged: (_) {},
popup: const SelectPopup(
items: SelectItemList(
children: [
SelectItemButton<String>(
value: "normal",
child: Text("Normal"),
),
SelectItemButton<String>(
value: "comfortable",
child: Text("Comfortable"),
),
],
),
),
),
),
),
),
],
),
),
),
),
);
Rect _rowOf(WidgetTester tester, String label) => tester.getRect(
find.ancestor(
of: find.text(label),
matching: find.byType(SelectItemButton<String>),
),
);
void main() {
testWidgets("theres air between one row and the next", (tester) async {
await tester.pumpWidget(_host());
await tester.tap(find.byType(Select<String>));
await tester.pumpAndSettle();
final first = _rowOf(tester, "Normal");
final second = _rowOf(tester, "Comfortable");
final gap = second.top - first.bottom;
expect(gap, greaterThan(0), reason: "rows were flush");
expect(gap, closeTo(_density.gapXxs, 0.5));
});
testWidgets("the surface rounds around the rows, less a couple of px", (
tester,
) async {
await tester.pumpWidget(_host());
await tester.tap(find.byType(Select<String>));
await tester.pumpAndSettle();
final theme = ThemeData(colorScheme: testScheme, density: _density);
final inset = theme.density.containerGap * 0.5;
final border = theme.density.controlBorderWidth;
// the surface is the one decorated box wrapping the rows
final surface = tester.widgetList<Container>(find.byType(Container)).where((
c,
) {
final d = c.decoration;
return d is BoxDecoration && d.borderRadius != null && d.border != null;
});
expect(surface, isNotEmpty);
final radii = surface
.map((c) => ((c.decoration as BoxDecoration).borderRadius
as BorderRadius).topLeft.x)
.toList();
final expected = theme.radiusMd + inset + border - 2;
expect(
radii.any((r) => (r - expected).abs() < 0.01),
isTrue,
reason: "wanted a surface at $expected, saw $radii",
);
});
testWidgets("it hangs off the trigger by gapSm, not the container gap", (
tester,
) async {
await tester.pumpWidget(_host());
final trigger = tester.getRect(find.byType(Select<String>));
await tester.tap(find.byType(Select<String>));
await tester.pumpAndSettle();
final scroller = tester.getRect(find.byType(SingleChildScrollView).first);
// the scroller starts just inside the surface's border, so back that off
// to get the surface's own top edge
final border = _density.controlBorderWidth;
final toSurface = scroller.top - border - trigger.bottom;
expect(toSurface, closeTo(_density.gapSm, 0.5));
expect(toSurface, lessThan(_density.containerGap));
});
testWidgets("the rows still sit inside the padding", (tester) async {
await tester.pumpWidget(_host());
await tester.tap(find.byType(Select<String>));
await tester.pumpAndSettle();
final row = _rowOf(tester, "Normal");
final theme = ThemeData(colorScheme: testScheme, density: _density);
final inset = theme.density.containerGap * 0.5;
// the scroller holds the padding, so the row starts one inset inside it
final scroller = tester.getRect(find.byType(SingleChildScrollView).first);
expect(row.left - scroller.left, closeTo(inset, 0.5));
expect(row.top - scroller.top, closeTo(inset, 0.5));
});
}
+137
View File
@@ -0,0 +1,137 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
Widget _select() => Select<String>(
value: "off",
itemBuilder: (ctx, v) => const Text("Off"),
onChanged: (_) {},
popup: SelectPopup(
items: SelectItemList(
children: const [
SelectItemButton<String>(value: "off", child: Text("Off")),
SelectItemButton<String>(
value: "optional",
child: Text("Optional"),
),
SelectItemButton<String>(
value: "required",
child: Text("Required"),
),
],
),
),
);
Widget _host({required Alignment where, double overlayWidth = 600}) =>
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: overlayWidth,
height: 400,
child: Overlay(
initialEntries: [
OverlayEntry(
builder: (_) => Align(
alignment: where,
// narrow, the way a ghost select showing "Off" is narrow
child: SizedBox(width: 70, child: _select()),
),
),
],
),
),
),
),
);
void main() {
testWidgets("it sizes to its widest row, not to the room available", (
tester,
) async {
// given a ceiling, a ListView based popup filled the ceiling - a 70px
// trigger opening a popup most of 600px wide.
await tester.pumpWidget(_host(where: Alignment.topLeft));
await tester.tap(find.byType(Select<String>));
await tester.pumpAndSettle();
final row = tester.getRect(
find.ancestor(
of: find.text("Required"),
matching: find.byType(SelectItemButton<String>),
),
);
// wide enough for the longest option and its tick, nowhere near the 600
// it was offered
expect(row.width, greaterThan(70));
expect(row.width, lessThan(300));
expect(tester.takeException(), isNull);
});
testWidgets("a narrow trigger near an edge still gets a readable popup", (
tester,
) async {
// the sliver: clamped to the room on the side the placement picked by
// testing the FLOOR, the popup came out one character wide.
await tester.pumpWidget(_host(where: Alignment.topRight));
await tester.tap(find.byType(Select<String>));
await tester.pumpAndSettle();
for (final label in ["Off", "Optional", "Required"]) {
final row = tester.getRect(
find.ancestor(
of: find.text(label),
matching: find.byType(SelectItemButton<String>),
),
);
// one line each, not a column of letters
expect(row.height, lessThan(40), reason: label);
// and never narrower than the trigger it came out of
expect(row.width, greaterThanOrEqualTo(70.0), reason: label);
}
expect(tester.takeException(), isNull);
});
testWidgets("the popup grows past a narrow trigger to fit its rows", (
tester,
) async {
await tester.pumpWidget(_host(where: Alignment.topLeft));
await tester.tap(find.byType(Select<String>));
await tester.pumpAndSettle();
// the row that wrapped onto two lines when the popup was pinned to a
// 70px trigger
final optional = tester.getSize(find.text("Optional"));
expect(optional.height, lessThan(30));
expect(tester.takeException(), isNull);
});
testWidgets("and it stays inside the overlay when the trigger is at the "
"right edge", (tester) async {
// the case that broke: a select hard against the right of a settings row.
// Left aligned to the trigger, a wider popup runs off the edge and gets
// clipped by whatever bounds it - a square corner and rows cut mid-word.
await tester.pumpWidget(_host(where: Alignment.topRight));
await tester.tap(find.byType(Select<String>));
await tester.pumpAndSettle();
for (final label in ["Off", "Optional", "Required"]) {
final row = tester.getRect(
find.ancestor(
of: find.text(label),
matching: find.byType(SelectItemButton<String>),
),
);
expect(row.left, greaterThanOrEqualTo(0.0), reason: label);
expect(row.right, lessThanOrEqualTo(600.0), reason: label);
}
expect(tester.takeException(), isNull);
});
}
+154
View File
@@ -0,0 +1,154 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
Widget _host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(width: 400, child: child),
),
),
);
void main() {
testWidgets("rules go between entries, not around them", (tester) async {
await tester.pumpWidget(
_host(
SettingsList(
children: [
SettingsRow(label: "Full name", field: const Text("Benjamin")),
SettingsRow(label: "Nickname", field: const Text("Benji")),
SettingsRow(label: "Work", field: const Text("Engineering")),
],
),
),
);
// three rows, two rules - a rule above the first or below the last would
// draw the box this exists to not be
expect(find.byType(Divider), findsNWidgets(2));
});
testWidgets("the rule runs wider than the rows it separates", (
tester,
) async {
await tester.pumpWidget(
_host(
SettingsList(
children: [
SettingsRow(label: "Read", field: const Text("Optional")),
SettingsRow(label: "Change", field: const Text("Optional")),
],
),
),
);
final rule = tester.getRect(find.byType(Divider));
final row = tester.getRect(find.byType(SettingsRow).first);
// overshoots at both ends - a rule that stops where its content stops
// reads as the edge of a box rather than a divider
expect(rule.left, lessThan(row.left));
expect(rule.right, greaterThan(row.right));
});
testWidgets("one entry has no rule at all", (tester) async {
await tester.pumpWidget(
_host(
SettingsList(
children: [
SettingsRow(label: "Full name", field: const Text("Benjamin")),
],
),
),
);
expect(find.byType(Divider), findsNothing);
});
testWidgets("label takes the room the field doesnt", (tester) async {
await tester.pumpWidget(
_host(
SettingsList(
children: [
SettingsRow(
label: "What best describes your work?",
field: const SizedBox(width: 120, height: 20),
),
],
),
),
);
final label = tester.getRect(find.text("What best describes your work?"));
final field = tester.getRect(find.byType(SizedBox).last);
final row = tester.getRect(find.byType(SettingsRow));
// label on the left, field hard against the right edge - not floating in
// the middle at the left of its own share of the row
expect(label.left, lessThan(field.left));
expect(field.width, 120);
expect(field.right, closeTo(row.right, 1));
expect(tester.takeException(), isNull);
});
testWidgets("a field with flex children inside it doesnt assert", (
tester,
) async {
// the field slot is bounded, not unbounded - a ButtonGroup with `fill`,
// or anything else holding an Expanded, needs a width to divide up.
await tester.pumpWidget(
_host(
SettingsList(
children: [
SettingsRow(
label: "Read",
field: ButtonGroup.horizontal(
fill: true,
children: [
Button(
style: const ButtonStyle.outline(),
onPressed: () {},
child: const Text("Off"),
),
Button(
style: const ButtonStyle.outline(),
onPressed: () {},
child: const Text("Required"),
),
],
),
),
],
),
),
);
expect(tester.takeException(), isNull);
});
testWidgets("a long label doesnt push the field off the row", (
tester,
) async {
await tester.pumpWidget(
_host(
SettingsList(
children: [
SettingsRow(
label: "See your account, passkeys and sign-in activity, and "
"everything else besides",
field: const SizedBox(width: 120, height: 20),
),
],
),
),
);
expect(tester.takeException(), isNull);
expect(tester.getSize(find.byType(SizedBox).last).width, 120);
});
}
+12
View File
@@ -0,0 +1,12 @@
import "dart:ui" show Color;
import "package:flutter/widgets.dart" show Brightness;
import "package:garage_ui/theme/colour_scheme.dart";
/// A throwaway scheme for widget tests, built through the deriving factory so
/// tests don't each carry a 54-colour literal.
final testScheme = ColourScheme.derive(
brightness: Brightness.dark,
background: const Color(0xff303030),
foreground: const Color(0xffe6e6e6),
primary: const Color(0xff4772b3),
);
+69
View File
@@ -0,0 +1,69 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// Every control's label is control type: density.fontSize at typography.medium.
// They sit next to each other in a popup or a form, so a menu row rendering a
// pixel larger and a weight lighter than the button beside it is a bug you can
// see. This pins them together.
void main() {
final theme = ThemeData(colorScheme: testScheme, density: const Density());
Future<TextStyle> resolve(WidgetTester t, Widget w) async {
await t.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: theme,
child: Center(child: SizedBox(width: 320, child: w)),
),
));
await t.pump();
final f = find.text("Label").first;
final widget = t.widget<Text>(f);
final inherited = DefaultTextStyle.of(t.element(f)).style;
return widget.style == null ? inherited : inherited.merge(widget.style);
}
final controls = <String, Widget>{
"Button": Button(
style: const ButtonStyle.ghost(),
onPressed: () {},
child: const Text("Label")),
"TextField": const TextField(placeholder: Text("Label")),
"Select": Select<String>(
value: "Label",
itemBuilder: (c, v) => Text(v),
popup: (c) => const SizedBox.shrink(),
onChanged: (_) {}),
"MenuButton": MenuPopup(
children: [MenuButton(onPressed: (_) {}, child: const Text("Label"))]),
};
for (final e in controls.entries) {
testWidgets("${e.key} label is control type", (t) async {
final s = await resolve(t, e.value);
expect(s.fontSize, theme.density.fontSize,
reason: "${e.key} should render at the control font");
expect(s.fontWeight, theme.typography.medium.fontWeight,
reason: "${e.key} should render at the control weight");
});
}
testWidgets("a menu row matches the button beside it exactly", (t) async {
final button = await resolve(t, controls["Button"]!);
final menu = await resolve(t, controls["MenuButton"]!);
expect(menu.fontSize, button.fontSize);
expect(menu.fontWeight, button.fontWeight);
expect(menu.color, button.color,
reason: "menu items read foreground, same slot as every other control");
});
testWidgets("a section label is heavier but the same size", (t) async {
final label = await resolve(
t, MenuPopup(children: const [MenuLabel(child: Text("Label"))]));
expect(label.fontSize, theme.density.fontSize);
expect(label.fontWeight, theme.typography.semiBold.fontWeight);
});
}
@@ -0,0 +1,56 @@
import "package:flutter/semantics.dart";
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// with the semantics tree on, flutter web focuses a field by asking through
// semantics rather than by a tap on the field. It used to go unanswered.
Widget _host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(),
child: GarageTheme(
data: ThemeData(colorScheme: testScheme),
child: Overlay(
initialEntries: [
OverlayEntry(
builder: (_) => Center(child: SizedBox(width: 300, child: child)),
),
],
),
),
),
);
void main() {
for (final action in [SemanticsAction.tap, SemanticsAction.focus]) {
testWidgets("$action from semantics focuses the field", (tester) async {
final handle = tester.ensureSemantics();
final focus = FocusNode();
addTearDown(focus.dispose);
await tester.pumpWidget(_host(TextField(focusNode: focus)));
expect(focus.hasFocus, isFalse);
final node = tester.getSemantics(find.byType(EditableText));
node.owner!.performAction(node.id, action);
await tester.pump();
expect(focus.hasFocus, isTrue);
handle.dispose();
});
}
testWidgets("a disabled field doesnt offer to be focused", (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(_host(const TextField(enabled: false)));
final node = tester.getSemantics(find.byType(EditableText));
final data = node.getSemanticsData();
expect(data.hasAction(SemanticsAction.tap), isFalse);
expect(data.hasAction(SemanticsAction.focus), isFalse);
handle.dispose();
});
}
+43
View File
@@ -0,0 +1,43 @@
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/theme/theme_data.dart";
// The whole point of the ladder: text that isn't in a control has to stay in
// step with the control font. If someone retunes fontSize and textXs stops
// tracking it, a dialog title drifts away from the button under it again.
void main() {
for (final d in const [Density(), Density.normal()]) {
final name = d.control.name;
test("$name: textXs sits a step above the control font", () {
expect(d.textXs, greaterThan(d.fontSize),
reason: "dialog copy is meant to read larger than a button label");
});
test("$name: the ladder climbs and stays on whole pixels", () {
final steps = [d.textXs, d.textSm, d.textLg];
for (var i = 1; i < steps.length; i++) {
expect(steps[i], greaterThan(steps[i - 1]));
}
for (final s in steps) {
expect(s, s.roundToDouble(), reason: "$s is not a whole pixel");
}
});
}
test("the two larger tiers keep the values they replaced", () {
// these were hardcoded 12/14/18 - a retune of fontSize is meant to move
// them, but at the shipped densities they must not shift at all.
for (final d in const [Density(), Density.normal()]) {
expect(d.textXs, 11.0);
expect(d.textSm, 14.0);
expect(d.textLg, 18.0);
}
});
test("a retuned density carries the whole ladder with it", () {
const big = Density(fontSize: 20);
expect(big.textXs, 22.0);
expect(big.textSm, 28.0);
expect(big.textLg, 36.0);
});
}