The Garage SDKs, in the open
garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme, docs under docs/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
This commit is contained in:
@@ -0,0 +1,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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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(":");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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!,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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!,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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!,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user