// 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; final EdgeInsetsGeometry? padding; final double? surfaceOpacity; final double? surfaceBlur; final double? width; final double? height; final Duration? duration; @override State createState() => _OutlinedContainerState(); } class _OutlinedContainerState extends State { 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 []; 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; 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; 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!, ], ), ); } }