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 headers; final List 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 createState() => _CircularProgressIndicatorState(); } class _CircularProgressIndicatorState extends State 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 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 = []; 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 createState() => _HoverCardState(); } class _HoverCardState extends State { 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, ), ); } }