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,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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user